Files
cthulhu/internal/app/speech_history_window.go

262 lines
6.4 KiB
Go

// 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
}
}
}
}
}