// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "sync" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type tentacleOutputTicket struct { ctx context.Context session context.Context speechGeneration uint64 speechRevision uint64 brailleSequence uint64 } // tentacleOutput owns one explicit command's presentation lifetime. Any later // command, foreground/mode transition or accessibility event retires it. This // deliberately conservative revision gate exposes no accessible text/positions. // Speech's interruption generation and braille's frame sequence are independent. // Lock order: runtime, focus gate, output owner, then speech/braille owner. type tentacleOutput struct { mutex sync.Mutex speech *speechWorker braille *brailleWorker gate *focusGate live *liveSettings current context.Context cancel context.CancelFunc } func (owner *tentacleOutput) Capture(parent context.Context) tentacleOutputTicket { if owner == nil { return tentacleOutputTicket{} } owner.mutex.Lock() defer owner.mutex.Unlock() owner.retireLocked() ctx, cancel := context.WithCancel(parent) owner.current, owner.cancel = ctx, cancel ticket := tentacleOutputTicket{ctx: ctx, session: parent} if owner.speech != nil { owner.speech.mutex.Lock() ticket.speechGeneration = owner.speech.generation ticket.speechRevision = owner.speech.presentationRevision owner.speech.mutex.Unlock() } if owner.braille != nil { owner.braille.mu.Lock() ticket.brailleSequence = owner.braille.sequence owner.braille.mu.Unlock() } return ticket } func (owner *tentacleOutput) Retire() { if owner == nil { return } owner.mutex.Lock() defer owner.mutex.Unlock() owner.retireLocked() } // Stop additionally revokes accepted protected speech during reader shutdown // or terminal handoff. Ordinary focus/command retirement only retires replies. func (owner *tentacleOutput) Stop() { if owner == nil { return } owner.mutex.Lock() defer owner.mutex.Unlock() owner.retireLocked() if owner.speech != nil { owner.speech.stop_uninterruptible() } } func (owner *tentacleOutput) RetireRevoked() { if owner == nil { return } owner.mutex.Lock() defer owner.mutex.Unlock() if owner.current != nil && owner.current.Err() != nil { owner.retireLocked() } } func (owner *tentacleOutput) retireLocked() { if owner.cancel == nil { return } owner.cancel() if owner.speech != nil { owner.speech.revokePresentation(owner.current) } if owner.braille != nil { owner.braille.retirePresentation(owner.current) } owner.current, owner.cancel = nil, nil } func (owner *tentacleOutput) Present(ticket tentacleOutputTicket, value tentaclewire.Presentation, command string) { if owner == nil || ticket.ctx == nil { return } if owner.gate != nil { owner.gate.mutex.Lock() defer owner.gate.mutex.Unlock() } owner.mutex.Lock() defer owner.mutex.Unlock() if ticket.ctx != owner.current || ticket.ctx.Err() != nil || owner.gate.Locked() || owner.gate.Suspended() { return } if value.Speech != "" && owner.speech != nil { request := speechRequest{Text: value.Speech, Role: "system", Cause: command, context: ticket.ctx, presentationGeneration: &ticket.speechGeneration, presentationRevision: &ticket.speechRevision} if value.Uninterruptible { // Admission still checks the ticket; accepted speech belongs to the // session so subsequent focus/command changes cannot cancel it. request.Uninterruptible = true request.context, request.cancel = context.WithTimeout(ticket.session, uninterruptibleSpeechTimeout) } owner.speech.Enqueue(request) } if value.Braille != "" && owner.braille != nil && owner.live != nil { settings := owner.live.Load() if settings.EnableBraille { if owner.braille.flash_message_guarded(value.Braille, settings.Braille, false, ticket.ctx, ticket.brailleSequence) { owner.braille.mu.Lock() if owner.braille.flashContext == ticket.ctx { owner.braille.logger.Debug("queued tentacle braille message", "command", command, "frame_id", owner.braille.frame.ID, "braille_text", owner.braille.frame.Text) } owner.braille.mu.Unlock() } } } }