// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bufio" "context" "encoding/json" "io" "log/slog" "os" "os/exec" "sync" "time" "git.stormux.org/storm/cthulhu/internal/platform/session" "git.stormux.org/storm/cthulhu/internal/speechhistoryui" ) const speechHistoryWindowTitle = "Speech History - Cthulhu" type speechHistoryWindows struct { logger *slog.Logger history *speechHistory speech speechEnqueuer gate *focusGate open chan struct{} refresh chan struct{} toggle chan struct{} done chan struct{} cancel context.CancelFunc once sync.Once } func newSpeechHistoryWindows(logger *slog.Logger, history *speechHistory, speech speechEnqueuer, gate *focusGate) *speechHistoryWindows { return &speechHistoryWindows{logger: logger, history: history, speech: speech, gate: gate, open: make(chan struct{}, 1), refresh: make(chan struct{}, 1), toggle: make(chan struct{}, 1), done: make(chan struct{})} } func (w *speechHistoryWindows) Refresh() { select { case w.refresh <- struct{}{}: default: } } func (w *speechHistoryWindows) Open() { select { case w.open <- struct{}{}: default: } } func (w *speechHistoryWindows) ToggleMonitor() { select { case w.toggle <- struct{}{}: default: } } func (w *speechHistoryWindows) Start(ctx context.Context) error { ctx, w.cancel = context.WithCancel(ctx) go w.run(ctx) return nil } func (w *speechHistoryWindows) Stop(ctx context.Context) error { w.once.Do(func() { if w.cancel != nil { w.cancel() } }) select { case <-w.done: return nil case <-ctx.Done(): return ctx.Err() } } func (w *speechHistoryWindows) announce(text string) { if w.gate != nil && (w.gate.Locked() || w.gate.Suspended()) { return } w.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "speech-history"}) } func (w *speechHistoryWindows) run(ctx context.Context) { defer close(w.done) var historyUpdates, monitorUpdates chan speechhistoryui.Message var historyDone, monitorDone chan struct{} var stopHistory, stopMonitor context.CancelFunc defer func() { if stopHistory != nil { stopHistory() } if stopMonitor != nil { stopMonitor() } if historyDone != nil { <-historyDone } if monitorDone != nil { <-monitorDone } w.history.setPaused(false) w.history.setMonitor(nil) }() for { select { case <-ctx.Done(): return case <-w.open: if historyUpdates != nil { select { case historyUpdates <- speechhistoryui.Message{Present: true}: default: } continue } childCtx, cancel := context.WithCancel(ctx) stopHistory = cancel historyUpdates = make(chan speechhistoryui.Message, 4) historyDone = make(chan struct{}) w.history.setPaused(true) go func() { defer close(historyDone) if err := w.window(childCtx, "history", historyUpdates, nil); err != nil && childCtx.Err() == nil { w.logger.Error("speech history window", "error", err) } }() case <-w.refresh: if historyUpdates != nil { update := speechhistoryui.Message{Items: append([]string{}, w.history.snapshot()...)} select { case historyUpdates <- update: case <-ctx.Done(): return } } case <-historyDone: historyUpdates, historyDone = nil, nil stopHistory = nil w.history.setPaused(false) case <-w.toggle: if monitorUpdates != nil { stopMonitor() w.announce("Speech monitor disabled.") continue } childCtx, cancel := context.WithCancel(ctx) stopMonitor = cancel monitorUpdates = make(chan speechhistoryui.Message, 128) monitorDone = make(chan struct{}) stream := make(chan string, 128) w.history.setMonitor(stream) go func() { defer close(monitorDone) if err := w.window(childCtx, "monitor", monitorUpdates, stream); err != nil && childCtx.Err() == nil { w.logger.Error("speech monitor window", "error", err) } }() w.announce("Speech monitor enabled.") case <-monitorDone: monitorUpdates, monitorDone = nil, nil stopMonitor = nil w.history.setMonitor(nil) } } } func (w *speechHistoryWindows) window(ctx context.Context, mode string, updates <-chan speechhistoryui.Message, stream <-chan string) error { executable, err := os.Executable() if err != nil { return err } childCtx, stop := context.WithCancel(ctx) defer stop() command := exec.CommandContext(childCtx, executable, "--speech-history-ui") command.Env = session.GTKEnvironment() command.WaitDelay = time.Second in, err := command.StdinPipe() if err != nil { return err } out, err := command.StdoutPipe() if err != nil { in.Close() return err } command.Stderr = os.Stderr if err := command.Start(); err != nil { in.Close() out.Close() return err } defer func() { stop(); in.Close(); out.Close(); _ = command.Wait() }() encoder := json.NewEncoder(in) initial := speechhistoryui.Message{Mode: mode} if mode == "history" { initial.Items = append([]string{}, w.history.snapshot()...) } if err := encoder.Encode(initial); err != nil { return err } requests := make(chan speechhistoryui.Request, 1) readDone := make(chan error, 1) go func() { scanner := bufio.NewScanner(out) scanner.Buffer(make([]byte, 4096), 1024*1024) for scanner.Scan() { var request speechhistoryui.Request if err := json.Unmarshal(scanner.Bytes(), &request); err != nil { readDone <- err return } select { case requests <- request: case <-childCtx.Done(): return } } readDone <- scanner.Err() }() for { select { case <-ctx.Done(): return ctx.Err() case err := <-readDone: if err == nil || err == io.EOF { return nil } return err case update := <-updates: if err := encoder.Encode(update); err != nil { return err } case text := <-stream: if text != "" { if err := encoder.Encode(speechhistoryui.Message{Append: text}); err != nil { return err } } case request := <-requests: switch request.Action { case "copy": w.announce("Copied to clipboard.") case "empty": w.announce("No speech history item selected.") case "remove": if request.Selection == "" { w.announce("No speech history item selected.") break } if w.history.remove(request.Selection) { w.announce("Removed from history.") } else { w.announce("Item not found in history.") } if err := encoder.Encode(speechhistoryui.Message{Items: append([]string{}, w.history.snapshot()...), Selected: 0}); err != nil { return err } } } } }