From 7c53cb39d469e861ee30928427f8c062d598c376 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 28 Oct 2026 20:46:12 -0400 Subject: [PATCH] Codex application override added. --- README.md | 2 +- docs/user.md | 75 +++- internal/app/application_overrides.go | 7 +- internal/app/application_overrides_test.go | 4 +- internal/app/codex_events.go | 47 +++ internal/app/codex_events_test.go | 152 ++++++++ internal/app/remote_settings.go | 15 +- internal/applicationoverrides/codex/codex.go | 169 +++++++++ .../applicationoverrides/codex/codex_test.go | 102 +++++ internal/applicationoverrides/override.go | 3 + internal/i18n/locale/de/LC_MESSAGES/fenrir.po | 94 +++-- internal/i18n/locale/es/LC_MESSAGES/fenrir.po | 96 +++-- internal/i18n/locale/fenrir.pot | 84 ++++- internal/i18n/locale/pl/LC_MESSAGES/fenrir.po | 95 +++-- internal/i18n/locale/pt/LC_MESSAGES/fenrir.po | 95 +++-- internal/i18n/locale/ru/LC_MESSAGES/fenrir.po | 95 +++-- internal/reader/application_feedback_test.go | 108 ++++++ internal/reader/application_override.go | 10 + internal/reader/progress.go | 20 +- internal/reader/reader.go | 14 +- tests/native_codex.py | 159 ++++++++ tests/test_codex_bridge.py | 130 +++++++ tools/codex_bridge.py | 350 ++++++++++++++++++ 23 files changed, 1771 insertions(+), 155 deletions(-) create mode 100644 internal/app/codex_events.go create mode 100644 internal/app/codex_events_test.go create mode 100644 internal/applicationoverrides/codex/codex.go create mode 100644 internal/applicationoverrides/codex/codex_test.go create mode 100644 internal/reader/application_feedback_test.go create mode 100644 tests/native_codex.py create mode 100644 tests/test_codex_bridge.py create mode 100644 tools/codex_bridge.py diff --git a/README.md b/README.md index e2e682b4..deb84239 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ See the [user manual](docs/user.md) for detailed usage documentation. - Desktop and laptop keyboard layouts, with an interactive tutorial. - A keyboard-operated settings menu and quick settings shortcuts. - Temporary built-in application overrides for Ninjam, NetworkManager's nmtui, - and classic Rogue. + classic Rogue, and Codex CLI (with an optional event bridge). - Speech Dispatcher and hardware speech synthesizer support. - Sound icons, progress tones, and indentation and text attribute feedback. - Multiple clipboards, optional persistent history, and X clipboard synchronization. diff --git a/docs/user.md b/docs/user.md index 5e4e72ca..2619c8c5 100644 --- a/docs/user.md +++ b/docs/user.md @@ -39,7 +39,8 @@ directory. For example: - Personal settings: `~/.local/share/stormux/fenrirscreenreader/settings/applications/ninjam.toml`. - With `--config /path/settings.toml`: `/path/applications/ninjam.toml`. -NetworkManager uses `networkmanager.toml`; Rogue uses `rogue.toml`. The directory +NetworkManager uses `networkmanager.toml`; Rogue uses `rogue.toml`; Codex uses +`codex.toml`. The directory and file are created on the first successful save. Files contain only customized settings; omitted values inherit ordinary settings followed by the built-in application defaults. @@ -151,6 +152,78 @@ touching the active reader: FENRIR_ROGUE_TEST_BINARY=/usr/bin/rogue go test ./internal/terminal -run TestRogueLiveOverride -v ``` +### Codex CLI + +The **Codex** override replaces automatic terminal diffs with completed assistant +messages. It suppresses tool output, streaming fragments, and redraws. Manual +screen review remains available, and typing echo is limited to the composer. +Messages are retained in memory even while speech is muted or automatic reading +is disabled. Existing lifecycle alerts remain separate from reply reading. +Left/Right navigation retains character feedback inside the composer. While +Codex is working, the existing activity tone sounds once every four seconds without +requiring redraws. It stops for approval/question waits and completion, respects +the sound and progress toggles, and remains independent of speech mute. + +| Shortcut | Codex information | +| --- | --- | +| Fenrir+1 through Fenrir+9 | Recent completed assistant messages, newest first | +| Fenrir+0 | Working, ready, waiting for approval or an answer, finished, or interrupted | + +Select **Codex** with **Fenrir+A**, type **c**, then Enter. Select **Default** +to leave the override. Without its bridge, the override suppresses automatic +screen changes and the status shortcut says **Waiting for Codex bridge**. + +The optional bridge requires Python 3 and Fenrir remote command control enabled. +Merge a command hook invoking `python3 /absolute/path/to/fenrir/tools/codex_bridge.py` +into each of these events in `~/.codex/hooks.json`: `SessionStart`, +`UserPromptSubmit`, `PermissionRequest`, `PostToolUse`, `Stop`, `Interrupt`, and +`SessionEnd`. For example, one event entry is: + +```json +{ + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "python3 /absolute/path/to/fenrir/tools/codex_bridge.py", "timeout": 3}]}] + } +} +``` + +Preserve existing hooks. Review and trust the added hooks through Codex's `/hooks` +interface; restart or resume Codex if required by the installed version. Starting +or resuming a conversation starts a detached bridge follower; submitting a prompt +also restarts it if necessary. The bridge never speaks or answers approvals. + +The follower reads only the transcript path supplied by that conversation's hook +and verifies its session identifier. It accepts completed assistant events, never +tool results, reasoning, or streamed deltas. It keeps the nine latest replies in +memory and writes only status, conversation identifiers, and lock files to a +private runtime directory. Switching conversations replaces the terminal binding; +packets from an older conversation cannot overwrite the new history. Existing +history is loaded silently on attachment or bridge restart. Long replies are +transported in chunks and announced only after assembly; the current per-message +limit is 1 MiB. The worker stops when Codex exits or a `SessionEnd` hook runs. + +X11 routing requires the same `WINDOWID` as the Fenrir instance; Linux console +routing uses Codex's controlling VT and the reader's existing authorization. +Other windows and consoles do not receive the replies. This local bridge does +not forward SSH audio or attach a remote Codex server to a local terminal. + +The transcript parser was checked against Codex CLI 0.165.0. It uses Codex's local +completed-event records, rather than a public subscription API; a changed record +format may require an update. Status comes from hooks and recorded turn events, +not the terminal's animated activity label. Check **Fenrir+0** after resuming. + +For a private protocol and keyboard test without live speech or Codex requests: + +```bash +go build -o fenrir ./cmd/fenrir +python3 -m unittest discover -s tests -p test_codex_bridge.py +xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_codex.py +``` + +Real acceptance still needs a Codex reply spoken once after completion, working +status on demand, history review, composer typing, approval/question handling, +and returning to ordinary reading after choosing Default or exiting Codex. + ## Starting and stopping Run these commands from the checkout root after building `fenrir`: diff --git a/internal/app/application_overrides.go b/internal/app/application_overrides.go index 432813cd..526dc279 100644 --- a/internal/app/application_overrides.go +++ b/internal/app/application_overrides.go @@ -9,6 +9,7 @@ import ( "slices" "git.stormux.org/storm/fenrir/internal/applicationoverrides" + "git.stormux.org/storm/fenrir/internal/applicationoverrides/codex" "git.stormux.org/storm/fenrir/internal/applicationoverrides/networkmanager" "git.stormux.org/storm/fenrir/internal/applicationoverrides/ninjam" "git.stormux.org/storm/fenrir/internal/applicationoverrides/rogue" @@ -189,6 +190,8 @@ func (a *application) select_override(id string) { } var adapter applicationoverrides.Adapter switch id { + case "codex": + adapter = &codex.Adapter{} case "ninjam": adapter = &ninjam.Adapter{} case "networkmanager": @@ -318,8 +321,8 @@ func (a *application) open_applications() { a.voice.Say(i18n.T("Settings are busy")) return } - items := []string{i18n.T("Default"), i18n.T("NetworkManager"), i18n.T("Ninjam"), i18n.T("Rogue")} - ids := []string{"default", "networkmanager", "ninjam", "rogue"} + items := []string{i18n.T("Default"), i18n.T("NetworkManager"), i18n.T("Ninjam"), i18n.T("Rogue"), i18n.T("Codex")} + ids := []string{"default", "networkmanager", "ninjam", "rogue", "codex"} position := 0 if selected := a.overrides[a.lastTerminal]; selected != nil { position = slices.Index(ids, selected.id) diff --git a/internal/app/application_overrides_test.go b/internal/app/application_overrides_test.go index aa17f00d..b47ec356 100644 --- a/internal/app/application_overrides_test.go +++ b/internal/app/application_overrides_test.go @@ -181,8 +181,9 @@ func TestApplicationsMenuActivatesOnlyOnEnterAndReleasesCapture(t *testing.T) { {"down", "NetworkManager"}, {"down", "Ninjam"}, {"down", "Rogue"}, + {"down", "Codex"}, {"down", "Default"}, - {"up", "Rogue"}, + {"up", "Codex"}, } { a.ui.handle(interaction.Key{Name: step.key}) if got := voice.messages[len(voice.messages)-1]; got != step.want { @@ -218,6 +219,7 @@ func TestApplicationsMenuActivatesOnlyOnEnterAndReleasesCapture(t *testing.T) { } a.open_applications() a.ui.handle(interaction.Key{Name: "down"}) // Rogue + a.ui.handle(interaction.Key{Name: "down"}) // Codex a.ui.handle(interaction.Key{Name: "down"}) // list wraps to Default a.ui.handle(interaction.Key{Name: "enter"}) if a.saves_unavailable() || a.engine.Tracking_mode() != "cursor" { diff --git a/internal/app/codex_events.go b/internal/app/codex_events.go new file mode 100644 index 00000000..2f6cc161 --- /dev/null +++ b/internal/app/codex_events.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "encoding/json" + "os" + "strings" + + "git.stormux.org/storm/fenrir/internal/applicationoverrides/codex" +) + +func (a *application) codex_event(text string) string { + kind, payload, _ := strings.Cut(text, " ") + if kind != "CODEX" { + return "Unsupported application event" + } + var event codex.Event + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return "Invalid Codex event JSON" + } + if a.options.TerminalMode { + if event.Window == "" || event.Window != os.Getenv("WINDOWID") || event.Terminal != 1 { + return "Codex event belongs to another terminal" + } + } else if event.Window != "" || event.Terminal != a.lastTerminal { + return "Codex event belongs to another terminal" + } + selected := a.overrides[event.Terminal] + if selected == nil || selected.id != "codex" { + return "Codex override is not selected" + } + if !selected.initialized || a.overrideTransition { + a.overrideDirty = true + return "Codex override is loading" + } + adapter, ok := selected.adapter.(*codex.Adapter) + if !ok { + return "Codex override is unavailable" + } + if err := adapter.Accept(event); err != nil { + return err.Error() + } + a.overrideDirty = true + return "" +} diff --git a/internal/app/codex_events_test.go b/internal/app/codex_events_test.go new file mode 100644 index 00000000..7daa7c0d --- /dev/null +++ b/internal/app/codex_events_test.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "encoding/json" + "os" + "slices" + "strings" + "testing" + "time" + + "git.stormux.org/storm/fenrir/internal/applicationoverrides/codex" + "git.stormux.org/storm/fenrir/internal/command" + "git.stormux.org/storm/fenrir/internal/control" +) + +func TestCodexEventRoutingWholeMessagesMuteReviewAndExit(t *testing.T) { + t.Setenv("WINDOWID", "42") + a, voice := persistence_application(t, true) + a.options.TerminalMode = true + snapshot := override_screen(1, "OpenAI Codex") + override_read(t, a, snapshot) + a.select_override("codex") + override_read(t, a, snapshot) + base := codex.Event{Version: 1, Epoch: 1, Window: "42", Terminal: 1, Session: "fixture-session", Kind: "status", Status: "working"} + send := func(event codex.Event, uid int) string { + t.Helper() + encoded, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + reply := make(chan string, 1) + a.remote_request(control.Request{UID: uid, Text: "APPLICATION CODEX " + string(encoded), Reply: reply}) + return <-reply + } + other := base + other.Window = "43" + if reply := send(other, os.Geteuid()); !strings.Contains(reply, "another terminal") { + t.Fatal(reply) + } + if reply := send(base, os.Geteuid()+1); !strings.Contains(reply, "not permitted") { + t.Fatal(reply) + } + a.settings.live.Remote.EnableCommandRemote = false + if reply := send(base, os.Geteuid()); !strings.Contains(reply, "disabled") { + t.Fatal(reply) + } + a.settings.live.Remote.EnableCommandRemote = true + if reply := send(base, os.Geteuid()); reply != "" { + t.Fatal(reply) + } + a.handle_command(command.ReadLiveRegion10) + if got := voice.messages[len(voice.messages)-1]; got != "Codex working" { + t.Fatal(got) + } + message := base + message.Kind, message.ID, message.Parts, message.Text = "message", "first", 2, "Complete " + start := len(voice.messages) + if reply := send(message, os.Geteuid()); reply != "" { + t.Fatal(reply) + } + override_read(t, a, snapshot) + if len(voice.messages) != start { + t.Fatal("partial text spoken", voice.messages[start:]) + } + message.Part, message.Text = 1, "message" + if reply := send(message, os.Geteuid()); reply != "" { + t.Fatal(reply) + } + override_read(t, a, snapshot) + if got := voice.messages[len(voice.messages)-1]; got != "Complete message" { + t.Fatal(got) + } + message.Part, message.Parts, message.Text = 0, 1, "Complete message" + start = len(voice.messages) + if reply := send(message, os.Geteuid()); reply != "" { + t.Fatal(reply) + } + override_read(t, a, override_screen(1, "file output and repeated streaming text")) + if len(voice.messages) != start { + t.Fatal("duplicate or tool output spoken", voice.messages[start:]) + } + a.handle_command(command.Mute) + message.ID, message.Text = "muted", "Message while muted" + if reply := send(message, os.Geteuid()); reply != "" { + t.Fatal(reply) + } + override_read(t, a, snapshot) + if len(voice.messages) != start { + t.Fatal("mute bypassed") + } + a.handle_command(command.Mute) + a.handle_command(command.ReadLiveRegion1) + if got := voice.messages[len(voice.messages)-1]; got != "Message while muted" { + t.Fatal(got) + } + // Stored replies must not replace raw screen review. + a.handle_command(command.CurrentLine) + if slices.Contains(voice.messages[len(voice.messages)-1:], "Message while muted") { + t.Fatal("history replaced raw review") + } + base.Status = "closed" + if reply := send(base, os.Geteuid()); reply != "" { + t.Fatal(reply) + } + override_read(t, a, override_screen(1, "[Username@host directory] $")) + if a.overrideCurrent != nil || a.overrides[1] != nil { + t.Fatal("shell did not restore defaults") + } +} + +func TestCodexWorkingToneUsesExistingSoundGates(t *testing.T) { + for _, mode := range []string{"enabled", "sound off", "progress off", "muted", "automatic off", "menu"} { + t.Run(mode, func(t *testing.T) { + a, _ := persistence_application(t, true) + snapshot := override_screen(1, "OpenAI Codex") + override_read(t, a, snapshot) + a.select_override("codex") + override_read(t, a, snapshot) + sounds := &fakeSounds{} + a.cues = sounds + a.settings.live.Presentation.Sound.Enabled = mode != "sound off" + a.settings.live.Presentation.Progress.Enabled = mode != "progress off" + a.settings.live.Reading.Automatic = mode != "automatic off" + a.configure_reader() + if mode == "muted" { + a.handle_command(command.Mute) + } + if mode == "menu" { + a.open_applications() + } + adapter := a.overrideCurrent.adapter.(*codex.Adapter) + if err := adapter.Accept(codex.Event{Version: 1, Epoch: 1, Session: "fixture-session", Kind: "status", Status: "working"}); err != nil { + t.Fatal(err) + } + override_read(t, a, snapshot) + want := mode == "enabled" || mode == "muted" || mode == "automatic off" + if (len(sounds.progress) > 0) != want { + t.Fatal(mode, sounds.progress) + } + if err := adapter.Accept(codex.Event{Version: 1, Epoch: 1, Session: "fixture-session", Kind: "status", Status: "permission"}); err != nil { + t.Fatal(err) + } + override_read(t, a, snapshot) + if _, ok := a.engine.Next_progress(time.Now().Add(2 * time.Second)); ok { + t.Fatal("approval wait retained working beep") + } + }) + } +} diff --git a/internal/app/remote_settings.go b/internal/app/remote_settings.go index ff693074..27cdde1b 100644 --- a/internal/app/remote_settings.go +++ b/internal/app/remote_settings.go @@ -188,6 +188,12 @@ func (a *application) remote_request(request control.Request) { } else { section, text, _ := strings.Cut(request.Text, " ") switch strings.ToUpper(section) { + case "APPLICATION": + if !a.settings.live.Remote.EnableCommandRemote { + response = "Command remote control is disabled" + } else { + response = a.codex_event(text) + } case "COMMAND": if !a.settings.live.Remote.EnableCommandRemote { response = "Command remote control is disabled" @@ -245,7 +251,14 @@ func (a *application) remote_request(request control.Request) { func remote_reply(reply chan string, response string) { if response != "" { - slog.Warn("remote request rejected", "error", response, "at", time.Now()) + // An opt-in bridge may be attached while its override is unselected, + // loading, or on another VT. Its normal retry must not fill the log. + switch response { + case "Codex override is not selected", "Codex override is loading", "Codex event belongs to another terminal": + slog.Debug("application event deferred", "reason", response, "at", time.Now()) + default: + slog.Warn("remote request rejected", "error", response, "at", time.Now()) + } } select { case reply <- response: diff --git a/internal/applicationoverrides/codex/codex.go b/internal/applicationoverrides/codex/codex.go new file mode 100644 index 00000000..9429f11c --- /dev/null +++ b/internal/applicationoverrides/codex/codex.go @@ -0,0 +1,169 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package codex presents completed messages supplied by the opt-in Codex bridge. +package codex + +import ( + "fmt" + "slices" + "strings" + + "git.stormux.org/storm/fenrir/internal/applicationoverrides" + "git.stormux.org/storm/fenrir/internal/command" + "git.stormux.org/storm/fenrir/internal/config" + "git.stormux.org/storm/fenrir/internal/i18n" + "git.stormux.org/storm/fenrir/internal/presentation" + "git.stormux.org/storm/fenrir/internal/screen" +) + +// Event is a versioned, terminal-addressed packet, never a reader command. +// Message chunks are committed only after the entire completed message arrives. +type Event struct { + Version int `json:"version"` + Window string `json:"window"` + Terminal int `json:"terminal"` + Session string `json:"session"` + Epoch int64 `json:"epoch"` + Kind string `json:"kind"` + Status string `json:"status,omitempty"` + ID string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + Part int `json:"part,omitempty"` + Parts int `json:"parts,omitempty"` + Baseline bool `json:"baseline,omitempty"` +} + +type storedMessage struct{ id, text string } + +type Adapter struct { + session, status string + epoch int64 + history []storedMessage + pending []presentation.Message + chunkID string + chunks []string + chunkBytes int + chunkParts int + closed bool +} + +func (*Adapter) Name() string { return "Codex" } +func (*Adapter) Configure(settings *config.Settings) { + settings.Review.LeaveReviewOnCursorChange = true +} + +// Suspension discards automatic announcements, retaining history and deduplication. +func (a *Adapter) Reset() { a.pending = nil } +func (a *Adapter) Close() { *a = Adapter{} } + +func (a *Adapter) Accept(event Event) error { + if event.Version != 1 || event.Session == "" || len(event.Session) > 128 || event.Epoch < 1 { + return fmt.Errorf("invalid Codex event version or session") + } + if event.Epoch < a.epoch || a.session != "" && a.session != event.Session && (event.Kind != "status" || event.Epoch <= a.epoch) { + return fmt.Errorf("another Codex session is attached") + } + if event.Kind == "status" { + switch event.Status { + case "idle", "working", "permission", "question", "done", "interrupted", "closed", "error": + default: + return fmt.Errorf("invalid Codex status") + } + } else if event.Kind != "message" || event.ID == "" || len(event.ID) > 256 || event.Parts < 1 || event.Parts > 4096 || event.Part < 0 || event.Part >= event.Parts || len(event.Text) > 6000 { + return fmt.Errorf("invalid Codex message chunk") + } + if a.session != event.Session { + *a = Adapter{session: event.Session, epoch: event.Epoch} + } + a.epoch = event.Epoch + if event.Kind == "status" { + a.status, a.closed = event.Status, event.Status == "closed" + return nil + } + if slices.ContainsFunc(a.history, func(message storedMessage) bool { return message.id == event.ID }) { + return nil + } + if event.Part == 0 { + a.chunkID, a.chunks, a.chunkBytes = event.ID, nil, 0 + a.chunkParts = event.Parts + } + if a.chunkID != event.ID || len(a.chunks) != event.Part || a.chunkParts != event.Parts { + return fmt.Errorf("Codex chunks must arrive in order") + } + a.chunkBytes += len(event.Text) + if a.chunkBytes > 1024*1024 { + a.chunkID, a.chunks, a.chunkBytes = "", nil, 0 + return fmt.Errorf("Codex message exceeds 1 MiB") + } + a.chunks = append(a.chunks, event.Text) + if len(a.chunks) != event.Parts { + return nil + } + text := strings.TrimSpace(strings.Join(a.chunks, "")) + a.chunkID, a.chunks, a.chunkBytes = "", nil, 0 + if text == "" { + return nil + } + a.history = append([]storedMessage{{event.ID, text}}, a.history...) + if len(a.history) > 9 { + a.history = a.history[:9] + } + if !event.Baseline { + a.pending = append(a.pending, presentation.Message{Text: text}) + } + return nil +} + +func (a *Adapter) Observe(next screen.Snapshot) applicationoverrides.Frame { + frame := applicationoverrides.Frame{Recognized: !a.closed, OwnsPresentation: !a.closed, InputCursor: true, Activity: !a.closed && a.status == "working"} + // Only the composer can produce automatic typing/deletion echo. Tool output + // and transcript rows must never be interpreted as text typed by the user. + for row := next.Rows - 1; row >= max(0, next.Rows-12); row-- { + line := string(next.Line(row)) + if col := strings.IndexRune(line, '›'); col >= 0 && strings.TrimSpace(line[:col]) == "" { + left := len([]rune(line[:col])) + 2 + if next.Y >= row && next.Y < next.Rows && next.X >= left { + frame.Input = &screen.Region{Left: left, Top: row, Right: next.Cols - 1, Bottom: next.Y} + } + break + } + } + frame.Updates, a.pending = a.pending, nil + return frame +} + +func (a *Adapter) Command(id command.ID) (presentation.CommandResult, bool) { + ids := []command.ID{command.ReadLiveRegion1, command.ReadLiveRegion2, command.ReadLiveRegion3, command.ReadLiveRegion4, command.ReadLiveRegion5, command.ReadLiveRegion6, command.ReadLiveRegion7, command.ReadLiveRegion8, command.ReadLiveRegion9} + if slot := slices.Index(ids, id); slot >= 0 { + text := i18n.T("No Codex message received") + if slot < len(a.history) { + text = a.history[slot].text + } + return presentation.CommandResult{Text: text}, true + } + if id == command.ListReadingRegions { + return presentation.CommandResult{Text: i18n.T("Codex regions: 1 to 9 recent messages, newest first; 0 status")}, true + } + if id == command.ReadLiveRegion10 { + text := i18n.T("Waiting for Codex bridge") + switch a.status { + case "idle": + text = i18n.T("Codex ready") + case "working": + text = i18n.T("Codex working") + case "permission": + text = i18n.T("Codex waiting for approval") + case "question": + text = i18n.T("Codex waiting for an answer") + case "done": + text = i18n.T("Codex finished") + case "interrupted": + text = i18n.T("Codex interrupted") + case "error": + text = i18n.T("Codex bridge error") + } + return presentation.CommandResult{Text: text}, true + } + return presentation.CommandResult{}, false +} diff --git a/internal/applicationoverrides/codex/codex_test.go b/internal/applicationoverrides/codex/codex_test.go new file mode 100644 index 00000000..38c80c32 --- /dev/null +++ b/internal/applicationoverrides/codex/codex_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package codex + +import ( + "strings" + "testing" + + "git.stormux.org/storm/fenrir/internal/command" + "git.stormux.org/storm/fenrir/internal/screen" +) + +func accept_event(t *testing.T, a *Adapter, event Event) { + t.Helper() + event.Version, event.Session, event.Epoch = 1, "fixture-session", 1 + if err := a.Accept(event); err != nil { + t.Fatal(err) + } +} + +func TestCompleteChunksDeduplicationHistoryAndStatus(t *testing.T) { + a := &Adapter{} + accept_event(t, a, Event{Kind: "status", Status: "working"}) + accept_event(t, a, Event{Kind: "message", ID: "first", Text: "Whole ", Parts: 2}) + if frame := a.Observe(screen.Snapshot{}); len(frame.Updates) != 0 { + t.Fatal("partial message spoken") + } + accept_event(t, a, Event{Kind: "message", ID: "first", Text: "reply", Part: 1, Parts: 2}) + frame := a.Observe(screen.Snapshot{}) + if !frame.OwnsPresentation || len(frame.Updates) != 1 || frame.Updates[0].Text != "Whole reply" { + t.Fatal(frame) + } + accept_event(t, a, Event{Kind: "message", ID: "first", Text: "Whole reply", Parts: 1}) + if len(a.Observe(screen.Snapshot{}).Updates) != 0 { + t.Fatal("duplicate spoken") + } + accept_event(t, a, Event{Kind: "message", ID: "second", Text: "Old reply", Parts: 1, Baseline: true}) + if len(a.Observe(screen.Snapshot{}).Updates) != 0 { + t.Fatal("baseline spoken") + } + if got, _ := a.Command(command.ReadLiveRegion1); got.Text != "Old reply" { + t.Fatal(got) + } + if got, _ := a.Command(command.ReadLiveRegion2); got.Text != "Whole reply" { + t.Fatal(got) + } + if got, _ := a.Command(command.ReadLiveRegion10); got.Text != "Codex working" { + t.Fatal(got) + } + a.Reset() + if got, _ := a.Command(command.ReadLiveRegion2); got.Text != "Whole reply" { + t.Fatal("suspension lost history") + } + accept_event(t, a, Event{Kind: "status", Status: "closed"}) + if a.Observe(screen.Snapshot{}).Recognized { + t.Fatal("exit retained presentation ownership") + } +} + +func TestRejectInterleavedSessionOutOfOrderAndOversize(t *testing.T) { + a := &Adapter{} + accept_event(t, a, Event{Kind: "status", Status: "idle"}) + if err := a.Accept(Event{Version: 1, Epoch: 1, Session: "another-session", Kind: "status", Status: "working"}); err == nil { + t.Fatal("another session attached") + } + if err := a.Accept(Event{Version: 1, Epoch: 1, Session: "fixture-session", Kind: "message", ID: "test", Part: 1, Parts: 2, Text: "tail"}); err == nil { + t.Fatal("out of order accepted") + } + if err := a.Accept(Event{Version: 1, Epoch: 1, Session: "fixture-session", Kind: "message", ID: "test", Parts: 1, Text: strings.Repeat("x", 6001)}); err == nil { + t.Fatal("oversize chunk accepted") + } +} + +func TestConversationSwitchRejectsPreviousFollower(t *testing.T) { + a := &Adapter{} + accept_event(t, a, Event{Kind: "status", Status: "working"}) + accept_event(t, a, Event{Kind: "message", ID: "old", Text: "Previous conversation", Parts: 1}) + if err := a.Accept(Event{Version: 1, Epoch: 2, Session: "new-session", Kind: "status", Status: "idle"}); err != nil { + t.Fatal(err) + } + if len(a.history) != 0 || len(a.pending) != 0 { + t.Fatal("conversation history leaked") + } + if err := a.Accept(Event{Version: 1, Epoch: 1, Session: "fixture-session", Kind: "status", Status: "working"}); err == nil { + t.Fatal("old follower replaced new conversation") + } +} + +func TestInputLimitedToComposer(t *testing.T) { + a := &Adapter{} + s := screen.Snapshot{Active: true, Rows: 8, Cols: 40, X: 5, Y: 6, Cells: []rune(strings.Repeat(" ", 320))} + copy(s.Line(6), []rune("› hello")) + frame := a.Observe(s) + if frame.Input == nil || frame.Input.Top != 6 || frame.Input.Left != 2 { + t.Fatal(frame.Input) + } + s.Y = 2 + if a.Observe(s).Input != nil { + t.Fatal("transcript treated as composer") + } +} diff --git a/internal/applicationoverrides/override.go b/internal/applicationoverrides/override.go index 49c2a08a..3042f763 100644 --- a/internal/applicationoverrides/override.go +++ b/internal/applicationoverrides/override.go @@ -39,6 +39,9 @@ type Frame struct { Regions []Region // Input limits typing/deletion echo to an application's actual input field. Input *screen.Region + // InputCursor preserves navigation feedback inside Input while automatic + // terminal diffs remain suppressed. Activity is indeterminate working state. + InputCursor, Activity bool } type Adapter interface { diff --git a/internal/i18n/locale/de/LC_MESSAGES/fenrir.po b/internal/i18n/locale/de/LC_MESSAGES/fenrir.po index b97d2546..7a71361d 100644 --- a/internal/i18n/locale/de/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/de/LC_MESSAGES/fenrir.po @@ -71,11 +71,11 @@ msgstr "Wütend" msgid "Apple" msgstr "Apfel" -#: internal/app/application_overrides.go:328 +#: internal/app/application_overrides.go:331 msgid "Application" msgstr "Anwendung" -#: internal/app/application_overrides.go:327 +#: internal/app/application_overrides.go:330 msgid "Applications" msgstr "Anwendungen" @@ -172,7 +172,7 @@ msgstr "Schmetterling" msgid "Canada" msgstr "Kanada" -#: internal/app/application_overrides.go:329 +#: internal/app/application_overrides.go:332 #: internal/preferences/settings.go:93 msgid "Cancel" msgstr "Abbrechen" @@ -274,6 +274,42 @@ msgstr "Import aus der Zwischenablage fehlgeschlagen" msgid "Clipboard sync" msgstr "Zwischenablage synchronisieren" +#: internal/app/application_overrides.go:324 +msgid "Codex" +msgstr "Codex" + +#: internal/applicationoverrides/codex/codex.go:164 +msgid "Codex bridge error" +msgstr "Fehler der Codex-Brücke" + +#: internal/applicationoverrides/codex/codex.go:160 +msgid "Codex finished" +msgstr "Codex fertig" + +#: internal/applicationoverrides/codex/codex.go:162 +msgid "Codex interrupted" +msgstr "Codex unterbrochen" + +#: internal/applicationoverrides/codex/codex.go:152 +msgid "Codex ready" +msgstr "Codex bereit" + +#: internal/applicationoverrides/codex/codex.go:146 +msgid "Codex regions: 1 to 9 recent messages, newest first; 0 status" +msgstr "Codex-Bereiche: 1 bis 9 letzte Nachrichten, neueste zuerst; 0 Status" + +#: internal/applicationoverrides/codex/codex.go:158 +msgid "Codex waiting for an answer" +msgstr "Codex wartet auf eine Antwort" + +#: internal/applicationoverrides/codex/codex.go:156 +msgid "Codex waiting for approval" +msgstr "Codex wartet auf Genehmigung" + +#: internal/applicationoverrides/codex/codex.go:154 +msgid "Codex working" +msgstr "Codex arbeitet" + #: internal/emoji/catalog.go:68 msgid "Coffee" msgstr "Kaffee" @@ -298,13 +334,13 @@ msgstr "Cool" msgid "Could not check spelling: {0}" msgstr "Rechtschreibung konnte nicht geprüft werden: {0}" -#: internal/app/application_overrides.go:281 -#: internal/app/application_overrides.go:84 +#: internal/app/application_overrides.go:284 +#: internal/app/application_overrides.go:85 msgid "Could not load application settings: {0}" msgstr "Anwendungseinstellungen konnten nicht geladen werden: {0}" -#: internal/app/application_overrides.go:332 -#: internal/app/application_overrides.go:336 +#: internal/app/application_overrides.go:335 +#: internal/app/application_overrides.go:339 msgid "Could not open Applications: {0}" msgstr "Anwendungen konnten nicht geöffnet werden: {0}" @@ -321,8 +357,8 @@ msgstr "Fenrir-Menü konnte nicht geöffnet werden: {0}" msgid "Could not open Spellcheck Menu: {0}" msgstr "Rechtschreibmenü konnte nicht geöffnet werden: {0}" -#: internal/app/application_overrides.go:162 -#: internal/app/application_overrides.go:299 +#: internal/app/application_overrides.go:163 +#: internal/app/application_overrides.go:302 msgid "Could not restore application settings: {0}" msgstr "Anwendungseinstellungen konnten nicht wiederhergestellt werden: {0}" @@ -351,12 +387,12 @@ msgstr "Dolch" msgid "Date format" msgstr "Datumsformat" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Default" msgstr "Standard" -#: internal/app/application_overrides.go:187 -#: internal/app/application_overrides.go:245 +#: internal/app/application_overrides.go:188 +#: internal/app/application_overrides.go:248 msgid "Default settings restored" msgstr "Standardeinstellungen wiederhergestellt" @@ -781,7 +817,7 @@ msgstr "Übelkeit" msgid "Netherlands" msgstr "Niederlande" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "NetworkManager" msgstr "NetworkManager" @@ -789,7 +825,7 @@ msgstr "NetworkManager" msgid "New channel" msgstr "Neuer Kanal" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Ninjam" msgstr "Ninjam" @@ -805,6 +841,10 @@ msgstr "" "Ninjam-Bereiche: 1 letzte Chatnachricht; 2 ausgewählter Kanal oder " "Bedienelement; 3 Verbindung, Tempo und Intervall" +#: internal/applicationoverrides/codex/codex.go:139 +msgid "No Codex message received" +msgstr "Keine Codex-Nachricht empfangen" + #: internal/applicationoverrides/ninjam/ninjam.go:79 msgid "No chat message received" msgstr "Keine Chatnachricht empfangen" @@ -1061,7 +1101,7 @@ msgstr "Ring" msgid "Rock on" msgstr "Rock-Geste" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Rogue" msgstr "Rogue" @@ -1161,8 +1201,8 @@ msgstr "Live-Bereich aus einer oder zwei Markierungen festlegen" msgid "Set window begin and end marks" msgstr "Setze Fensteranfangs- und -endmarken" -#: internal/app/application_overrides.go:172 -#: internal/app/application_overrides.go:318 internal/app/commands.go:84 +#: internal/app/application_overrides.go:173 +#: internal/app/application_overrides.go:321 internal/app/commands.go:84 msgid "Settings are busy" msgstr "Einstellungen sind beschäftigt" @@ -1440,6 +1480,10 @@ msgstr "Lautstärke und Panorama {0}" msgid "WTF?" msgstr "Was zum Teufel?" +#: internal/applicationoverrides/codex/codex.go:149 +msgid "Waiting for Codex bridge" +msgstr "Warten auf die Codex-Brücke" + #: internal/applicationoverrides/rogue/rogue.go:258 msgid "Wall" msgstr "Wand" @@ -1539,11 +1583,11 @@ msgstr "Pfeil rechts" msgid "at" msgstr "At-Zeichen" -#: internal/reader/reader.go:247 +#: internal/reader/reader.go:259 msgid "autoread disabled" msgstr "Automatisches Vorlesen ausgeschaltet" -#: internal/reader/reader.go:245 +#: internal/reader/reader.go:257 msgid "autoread enabled" msgstr "Automatisches Vorlesen eingeschaltet" @@ -1693,7 +1737,7 @@ msgstr "Aktuelle Zeile" msgid "current word." msgstr "Aktuelles Wort." -#: internal/config/definitions.go:225 internal/reader/reader.go:240 +#: internal/config/definitions.go:225 internal/reader/reader.go:252 msgid "cursor tracking" msgstr "Cursor-Verfolgung" @@ -1894,7 +1938,7 @@ msgstr "Kopfzeile festgelegt" msgid "heart" msgstr "Herz" -#: internal/config/definitions.go:225 internal/reader/reader.go:237 +#: internal/config/definitions.go:225 internal/reader/reader.go:249 msgid "highlight tracking" msgstr "Hervorhebungen verfolgen" @@ -2217,7 +2261,7 @@ msgstr "Zeile {0}, Spalte {1} bis Zeile {2}, Spalte {3}" msgid "screen is empty" msgstr "Bildschirm ist leer" -#: internal/reader/reader.go:155 +#: internal/reader/reader.go:158 msgid "screen {0}" msgstr "Bildschirm {0}" @@ -2367,7 +2411,7 @@ msgstr "überrascht" msgid "tab" msgstr "Tabulator" -#: internal/config/definitions.go:225 internal/reader/reader.go:234 +#: internal/config/definitions.go:225 internal/reader/reader.go:246 msgid "table mode" msgstr "Tabellenmodus" @@ -2393,7 +2437,7 @@ msgid "unavailable" msgstr "nicht verfügbar" #: internal/app/tutorial.go:107 internal/app/tutorial.go:161 -#: internal/reader/reader.go:249 internal/review/review.go:140 +#: internal/reader/reader.go:261 internal/review/review.go:140 msgid "unbound" msgstr "unbelegt" @@ -2425,7 +2469,7 @@ msgstr "Zwinkern" msgid "{0} added to dictionary" msgstr "{0} zum Wörterbuch hinzugefügt" -#: internal/app/application_overrides.go:210 +#: internal/app/application_overrides.go:213 msgid "{0} application override selected" msgstr "Anwendungseinstellungen für {0} ausgewählt" diff --git a/internal/i18n/locale/es/LC_MESSAGES/fenrir.po b/internal/i18n/locale/es/LC_MESSAGES/fenrir.po index cea08bc1..976f8b83 100644 --- a/internal/i18n/locale/es/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/es/LC_MESSAGES/fenrir.po @@ -71,11 +71,11 @@ msgstr "Enfadado" msgid "Apple" msgstr "Manzana" -#: internal/app/application_overrides.go:328 +#: internal/app/application_overrides.go:331 msgid "Application" msgstr "Aplicación" -#: internal/app/application_overrides.go:327 +#: internal/app/application_overrides.go:330 msgid "Applications" msgstr "Aplicaciones" @@ -172,7 +172,7 @@ msgstr "Mariposa" msgid "Canada" msgstr "Canadá" -#: internal/app/application_overrides.go:329 +#: internal/app/application_overrides.go:332 #: internal/preferences/settings.go:93 msgid "Cancel" msgstr "Cancelar" @@ -274,6 +274,44 @@ msgstr "Error al importar el portapapeles" msgid "Clipboard sync" msgstr "Sincronización del portapapeles" +#: internal/app/application_overrides.go:324 +msgid "Codex" +msgstr "Codex" + +#: internal/applicationoverrides/codex/codex.go:164 +msgid "Codex bridge error" +msgstr "Error del puente de Codex" + +#: internal/applicationoverrides/codex/codex.go:160 +msgid "Codex finished" +msgstr "Codex ha terminado" + +#: internal/applicationoverrides/codex/codex.go:162 +msgid "Codex interrupted" +msgstr "Codex interrumpido" + +#: internal/applicationoverrides/codex/codex.go:152 +msgid "Codex ready" +msgstr "Codex listo" + +#: internal/applicationoverrides/codex/codex.go:146 +msgid "Codex regions: 1 to 9 recent messages, newest first; 0 status" +msgstr "" +"Regiones de Codex: 1 a 9 mensajes recientes, el más reciente primero; 0 " +"estado" + +#: internal/applicationoverrides/codex/codex.go:158 +msgid "Codex waiting for an answer" +msgstr "Codex espera una respuesta" + +#: internal/applicationoverrides/codex/codex.go:156 +msgid "Codex waiting for approval" +msgstr "Codex espera aprobación" + +#: internal/applicationoverrides/codex/codex.go:154 +msgid "Codex working" +msgstr "Codex está trabajando" + #: internal/emoji/catalog.go:68 msgid "Coffee" msgstr "Café" @@ -298,13 +336,13 @@ msgstr "Genial" msgid "Could not check spelling: {0}" msgstr "No se pudo revisar la ortografía: {0}" -#: internal/app/application_overrides.go:281 -#: internal/app/application_overrides.go:84 +#: internal/app/application_overrides.go:284 +#: internal/app/application_overrides.go:85 msgid "Could not load application settings: {0}" msgstr "No se pudo cargar la configuración de la aplicación: {0}" -#: internal/app/application_overrides.go:332 -#: internal/app/application_overrides.go:336 +#: internal/app/application_overrides.go:335 +#: internal/app/application_overrides.go:339 msgid "Could not open Applications: {0}" msgstr "No se pudo abrir Aplicaciones: {0}" @@ -321,8 +359,8 @@ msgstr "No se pudo abrir el menú de Fenrir: {0}" msgid "Could not open Spellcheck Menu: {0}" msgstr "No se pudo abrir el menú de ortografía: {0}" -#: internal/app/application_overrides.go:162 -#: internal/app/application_overrides.go:299 +#: internal/app/application_overrides.go:163 +#: internal/app/application_overrides.go:302 msgid "Could not restore application settings: {0}" msgstr "No se pudo restaurar la configuración de la aplicación: {0}" @@ -351,12 +389,12 @@ msgstr "Daga" msgid "Date format" msgstr "Formato de fecha" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Default" msgstr "Predeterminado" -#: internal/app/application_overrides.go:187 -#: internal/app/application_overrides.go:245 +#: internal/app/application_overrides.go:188 +#: internal/app/application_overrides.go:248 msgid "Default settings restored" msgstr "Configuración predeterminada restaurada" @@ -789,7 +827,7 @@ msgstr "Náuseas" msgid "Netherlands" msgstr "Países Bajos" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "NetworkManager" msgstr "NetworkManager" @@ -797,7 +835,7 @@ msgstr "NetworkManager" msgid "New channel" msgstr "Nuevo canal" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Ninjam" msgstr "Ninjam" @@ -813,6 +851,10 @@ msgstr "" "Regiones de Ninjam: 1 último mensaje de chat; 2 canal o control " "seleccionado; 3 conexión, tempo e intervalo" +#: internal/applicationoverrides/codex/codex.go:139 +msgid "No Codex message received" +msgstr "No se ha recibido ningún mensaje de Codex" + #: internal/applicationoverrides/ninjam/ninjam.go:79 msgid "No chat message received" msgstr "No se ha recibido ningún mensaje de chat" @@ -1068,7 +1110,7 @@ msgstr "Anillo" msgid "Rock on" msgstr "Gesto de rock" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Rogue" msgstr "Rogue" @@ -1168,8 +1210,8 @@ msgstr "Definir una región dinámica a partir de una o dos marcas" msgid "Set window begin and end marks" msgstr "Establece una marca de principio y otra para el final de la ventana" -#: internal/app/application_overrides.go:172 -#: internal/app/application_overrides.go:318 internal/app/commands.go:84 +#: internal/app/application_overrides.go:173 +#: internal/app/application_overrides.go:321 internal/app/commands.go:84 msgid "Settings are busy" msgstr "La configuración está ocupada" @@ -1445,6 +1487,10 @@ msgstr "Volumen y panorámica {0}" msgid "WTF?" msgstr "¿Pero qué demonios?" +#: internal/applicationoverrides/codex/codex.go:149 +msgid "Waiting for Codex bridge" +msgstr "Esperando al puente de Codex" + #: internal/applicationoverrides/rogue/rogue.go:258 msgid "Wall" msgstr "Pared" @@ -1544,11 +1590,11 @@ msgstr "flecha derecha" msgid "at" msgstr "arroba" -#: internal/reader/reader.go:247 +#: internal/reader/reader.go:259 msgid "autoread disabled" msgstr "Lectura automática desactivada" -#: internal/reader/reader.go:245 +#: internal/reader/reader.go:257 msgid "autoread enabled" msgstr "Lectura automática activada" @@ -1698,7 +1744,7 @@ msgstr "línea actual" msgid "current word." msgstr "Palabra actual." -#: internal/config/definitions.go:225 internal/reader/reader.go:240 +#: internal/config/definitions.go:225 internal/reader/reader.go:252 msgid "cursor tracking" msgstr "Seguimiento del cursor" @@ -1901,7 +1947,7 @@ msgstr "fila de encabezado definida" msgid "heart" msgstr "corazón" -#: internal/config/definitions.go:225 internal/reader/reader.go:237 +#: internal/config/definitions.go:225 internal/reader/reader.go:249 msgid "highlight tracking" msgstr "Seguimiento del texto resaltado" @@ -2225,7 +2271,7 @@ msgstr "fila {0}, columna {1} hasta fila {2}, columna {3}" msgid "screen is empty" msgstr "La pantalla está vacía" -#: internal/reader/reader.go:155 +#: internal/reader/reader.go:158 msgid "screen {0}" msgstr "pantalla {0}" @@ -2373,7 +2419,7 @@ msgstr "sorprendido" msgid "tab" msgstr "tabulación" -#: internal/config/definitions.go:225 internal/reader/reader.go:234 +#: internal/config/definitions.go:225 internal/reader/reader.go:246 msgid "table mode" msgstr "modo de tabla" @@ -2399,7 +2445,7 @@ msgid "unavailable" msgstr "no disponible" #: internal/app/tutorial.go:107 internal/app/tutorial.go:161 -#: internal/reader/reader.go:249 internal/review/review.go:140 +#: internal/reader/reader.go:261 internal/review/review.go:140 msgid "unbound" msgstr "sin asignar" @@ -2431,7 +2477,7 @@ msgstr "guiño" msgid "{0} added to dictionary" msgstr "{0} añadido al diccionario" -#: internal/app/application_overrides.go:210 +#: internal/app/application_overrides.go:213 msgid "{0} application override selected" msgstr "Ajustes de aplicación seleccionados para {0}" diff --git a/internal/i18n/locale/fenrir.pot b/internal/i18n/locale/fenrir.pot index c9aa7333..2da03187 100644 --- a/internal/i18n/locale/fenrir.pot +++ b/internal/i18n/locale/fenrir.pot @@ -61,11 +61,11 @@ msgstr "" msgid "Apple" msgstr "" -#: internal/app/application_overrides.go:328 +#: internal/app/application_overrides.go:331 msgid "Application" msgstr "" -#: internal/app/application_overrides.go:327 +#: internal/app/application_overrides.go:330 msgid "Applications" msgstr "" @@ -161,7 +161,7 @@ msgstr "" msgid "Canada" msgstr "" -#: internal/app/application_overrides.go:329 internal/preferences/settings.go:93 +#: internal/app/application_overrides.go:332 internal/preferences/settings.go:93 msgid "Cancel" msgstr "" @@ -261,6 +261,42 @@ msgstr "" msgid "Clipboard sync" msgstr "" +#: internal/app/application_overrides.go:324 +msgid "Codex" +msgstr "" + +#: internal/applicationoverrides/codex/codex.go:164 +msgid "Codex bridge error" +msgstr "" + +#: internal/applicationoverrides/codex/codex.go:160 +msgid "Codex finished" +msgstr "" + +#: internal/applicationoverrides/codex/codex.go:162 +msgid "Codex interrupted" +msgstr "" + +#: internal/applicationoverrides/codex/codex.go:152 +msgid "Codex ready" +msgstr "" + +#: internal/applicationoverrides/codex/codex.go:146 +msgid "Codex regions: 1 to 9 recent messages, newest first; 0 status" +msgstr "" + +#: internal/applicationoverrides/codex/codex.go:158 +msgid "Codex waiting for an answer" +msgstr "" + +#: internal/applicationoverrides/codex/codex.go:156 +msgid "Codex waiting for approval" +msgstr "" + +#: internal/applicationoverrides/codex/codex.go:154 +msgid "Codex working" +msgstr "" + #: internal/emoji/catalog.go:68 msgid "Coffee" msgstr "" @@ -285,11 +321,11 @@ msgstr "" msgid "Could not check spelling: {0}" msgstr "" -#: internal/app/application_overrides.go:281 internal/app/application_overrides.go:84 +#: internal/app/application_overrides.go:284 internal/app/application_overrides.go:85 msgid "Could not load application settings: {0}" msgstr "" -#: internal/app/application_overrides.go:332 internal/app/application_overrides.go:336 +#: internal/app/application_overrides.go:335 internal/app/application_overrides.go:339 msgid "Could not open Applications: {0}" msgstr "" @@ -305,7 +341,7 @@ msgstr "" msgid "Could not open Spellcheck Menu: {0}" msgstr "" -#: internal/app/application_overrides.go:162 internal/app/application_overrides.go:299 +#: internal/app/application_overrides.go:163 internal/app/application_overrides.go:302 msgid "Could not restore application settings: {0}" msgstr "" @@ -333,11 +369,11 @@ msgstr "" msgid "Date format" msgstr "" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Default" msgstr "" -#: internal/app/application_overrides.go:187 internal/app/application_overrides.go:245 +#: internal/app/application_overrides.go:188 internal/app/application_overrides.go:248 msgid "Default settings restored" msgstr "" @@ -749,7 +785,7 @@ msgstr "" msgid "Netherlands" msgstr "" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "NetworkManager" msgstr "" @@ -757,7 +793,7 @@ msgstr "" msgid "New channel" msgstr "" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Ninjam" msgstr "" @@ -769,6 +805,10 @@ msgstr "" msgid "Ninjam regions: 1 latest chat message; 2 selected channel or control; 3 connection, tempo and interval" msgstr "" +#: internal/applicationoverrides/codex/codex.go:139 +msgid "No Codex message received" +msgstr "" + #: internal/applicationoverrides/ninjam/ninjam.go:79 msgid "No chat message received" msgstr "" @@ -1021,7 +1061,7 @@ msgstr "" msgid "Rock on" msgstr "" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Rogue" msgstr "" @@ -1121,7 +1161,7 @@ msgstr "" msgid "Set window begin and end marks" msgstr "" -#: internal/app/application_overrides.go:172 internal/app/application_overrides.go:318 internal/app/commands.go:84 +#: internal/app/application_overrides.go:173 internal/app/application_overrides.go:321 internal/app/commands.go:84 msgid "Settings are busy" msgstr "" @@ -1393,6 +1433,10 @@ msgstr "" msgid "WTF?" msgstr "" +#: internal/applicationoverrides/codex/codex.go:149 +msgid "Waiting for Codex bridge" +msgstr "" + #: internal/applicationoverrides/rogue/rogue.go:258 msgid "Wall" msgstr "" @@ -1485,11 +1529,11 @@ msgstr "" msgid "at" msgstr "" -#: internal/reader/reader.go:247 +#: internal/reader/reader.go:259 msgid "autoread disabled" msgstr "" -#: internal/reader/reader.go:245 +#: internal/reader/reader.go:257 msgid "autoread enabled" msgstr "" @@ -1629,7 +1673,7 @@ msgstr "" msgid "current word." msgstr "" -#: internal/config/definitions.go:225 internal/reader/reader.go:240 +#: internal/config/definitions.go:225 internal/reader/reader.go:252 msgid "cursor tracking" msgstr "" @@ -1817,7 +1861,7 @@ msgstr "" msgid "heart" msgstr "" -#: internal/config/definitions.go:225 internal/reader/reader.go:237 +#: internal/config/definitions.go:225 internal/reader/reader.go:249 msgid "highlight tracking" msgstr "" @@ -2117,7 +2161,7 @@ msgstr "" msgid "screen is empty" msgstr "" -#: internal/reader/reader.go:155 +#: internal/reader/reader.go:158 msgid "screen {0}" msgstr "" @@ -2261,7 +2305,7 @@ msgstr "" msgid "tab" msgstr "" -#: internal/config/definitions.go:225 internal/reader/reader.go:234 +#: internal/config/definitions.go:225 internal/reader/reader.go:246 msgid "table mode" msgstr "" @@ -2285,7 +2329,7 @@ msgstr "" msgid "unavailable" msgstr "" -#: internal/app/tutorial.go:107 internal/app/tutorial.go:161 internal/reader/reader.go:249 internal/review/review.go:140 +#: internal/app/tutorial.go:107 internal/app/tutorial.go:161 internal/reader/reader.go:261 internal/review/review.go:140 msgid "unbound" msgstr "" @@ -2317,7 +2361,7 @@ msgstr "" msgid "{0} added to dictionary" msgstr "" -#: internal/app/application_overrides.go:210 +#: internal/app/application_overrides.go:213 msgid "{0} application override selected" msgstr "" diff --git a/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po b/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po index 9d584c0e..8ed59581 100644 --- a/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po @@ -72,11 +72,11 @@ msgstr "Złość" msgid "Apple" msgstr "Jabłko" -#: internal/app/application_overrides.go:328 +#: internal/app/application_overrides.go:331 msgid "Application" msgstr "Aplikacja" -#: internal/app/application_overrides.go:327 +#: internal/app/application_overrides.go:330 msgid "Applications" msgstr "Aplikacje" @@ -173,7 +173,7 @@ msgstr "Motyl" msgid "Canada" msgstr "Kanada" -#: internal/app/application_overrides.go:329 +#: internal/app/application_overrides.go:332 #: internal/preferences/settings.go:93 msgid "Cancel" msgstr "Anuluj" @@ -275,6 +275,43 @@ msgstr "Importowanie schowka nie powiodło się" msgid "Clipboard sync" msgstr "Synchronizacja schowka" +#: internal/app/application_overrides.go:324 +msgid "Codex" +msgstr "Codex" + +#: internal/applicationoverrides/codex/codex.go:164 +msgid "Codex bridge error" +msgstr "Błąd mostu Codex" + +#: internal/applicationoverrides/codex/codex.go:160 +msgid "Codex finished" +msgstr "Codex zakończył pracę" + +#: internal/applicationoverrides/codex/codex.go:162 +msgid "Codex interrupted" +msgstr "Codex przerwany" + +#: internal/applicationoverrides/codex/codex.go:152 +msgid "Codex ready" +msgstr "Codex gotowy" + +#: internal/applicationoverrides/codex/codex.go:146 +msgid "Codex regions: 1 to 9 recent messages, newest first; 0 status" +msgstr "" +"Obszary Codex: od 1 do 9 ostatnie wiadomości, najnowsze najpierw; 0 stan" + +#: internal/applicationoverrides/codex/codex.go:158 +msgid "Codex waiting for an answer" +msgstr "Codex czeka na odpowiedź" + +#: internal/applicationoverrides/codex/codex.go:156 +msgid "Codex waiting for approval" +msgstr "Codex czeka na zatwierdzenie" + +#: internal/applicationoverrides/codex/codex.go:154 +msgid "Codex working" +msgstr "Codex pracuje" + #: internal/emoji/catalog.go:68 msgid "Coffee" msgstr "Kawa" @@ -299,13 +336,13 @@ msgstr "Wyluzowanie" msgid "Could not check spelling: {0}" msgstr "Nie można sprawdzić pisowni: {0}" -#: internal/app/application_overrides.go:281 -#: internal/app/application_overrides.go:84 +#: internal/app/application_overrides.go:284 +#: internal/app/application_overrides.go:85 msgid "Could not load application settings: {0}" msgstr "Nie można wczytać ustawień aplikacji: {0}" -#: internal/app/application_overrides.go:332 -#: internal/app/application_overrides.go:336 +#: internal/app/application_overrides.go:335 +#: internal/app/application_overrides.go:339 msgid "Could not open Applications: {0}" msgstr "Nie można otworzyć menu aplikacji: {0}" @@ -322,8 +359,8 @@ msgstr "Nie można otworzyć menu Fenrira: {0}" msgid "Could not open Spellcheck Menu: {0}" msgstr "Nie można otworzyć menu sprawdzania pisowni: {0}" -#: internal/app/application_overrides.go:162 -#: internal/app/application_overrides.go:299 +#: internal/app/application_overrides.go:163 +#: internal/app/application_overrides.go:302 msgid "Could not restore application settings: {0}" msgstr "Nie można przywrócić ustawień aplikacji: {0}" @@ -352,12 +389,12 @@ msgstr "Sztylet" msgid "Date format" msgstr "Format daty" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Default" msgstr "Domyślne" -#: internal/app/application_overrides.go:187 -#: internal/app/application_overrides.go:245 +#: internal/app/application_overrides.go:188 +#: internal/app/application_overrides.go:248 msgid "Default settings restored" msgstr "Przywrócono ustawienia domyślne" @@ -785,7 +822,7 @@ msgstr "Nudności" msgid "Netherlands" msgstr "Holandia" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "NetworkManager" msgstr "NetworkManager" @@ -793,7 +830,7 @@ msgstr "NetworkManager" msgid "New channel" msgstr "Nowy kanał" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Ninjam" msgstr "Ninjam" @@ -809,6 +846,10 @@ msgstr "" "Regiony Ninjam: 1 ostatnia wiadomość czatu; 2 wybrany kanał lub element " "sterujący; 3 połączenie, tempo i interwał" +#: internal/applicationoverrides/codex/codex.go:139 +msgid "No Codex message received" +msgstr "Nie otrzymano wiadomości od Codex" + #: internal/applicationoverrides/ninjam/ninjam.go:79 msgid "No chat message received" msgstr "Nie odebrano wiadomości czatu" @@ -1063,7 +1104,7 @@ msgstr "Pierścień" msgid "Rock on" msgstr "Gest rockowy" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Rogue" msgstr "Rogue" @@ -1163,8 +1204,8 @@ msgstr "Ustaw obszar dynamiczny za pomocą jednego lub dwóch znaczników" msgid "Set window begin and end marks" msgstr "Ustaw początek i koniec okna." -#: internal/app/application_overrides.go:172 -#: internal/app/application_overrides.go:318 internal/app/commands.go:84 +#: internal/app/application_overrides.go:173 +#: internal/app/application_overrides.go:321 internal/app/commands.go:84 msgid "Settings are busy" msgstr "Ustawienia są zajęte" @@ -1441,6 +1482,10 @@ msgstr "Głośność i panorama {0}" msgid "WTF?" msgstr "Co do diabła?" +#: internal/applicationoverrides/codex/codex.go:149 +msgid "Waiting for Codex bridge" +msgstr "Oczekiwanie na most Codex" + #: internal/applicationoverrides/rogue/rogue.go:258 msgid "Wall" msgstr "Ściana" @@ -1540,11 +1585,11 @@ msgstr "strzałka w prawo" msgid "at" msgstr "małpa" -#: internal/reader/reader.go:247 +#: internal/reader/reader.go:259 msgid "autoread disabled" msgstr "Wyłączono automatyczne ogłaszanie dynamicznych zmian treści." -#: internal/reader/reader.go:245 +#: internal/reader/reader.go:257 msgid "autoread enabled" msgstr "Włączono ogłaszanie dynamicznych zmian treści." @@ -1694,7 +1739,7 @@ msgstr "Aktualna linia" msgid "current word." msgstr "Aktualne słowo" -#: internal/config/definitions.go:225 internal/reader/reader.go:240 +#: internal/config/definitions.go:225 internal/reader/reader.go:252 msgid "cursor tracking" msgstr "Śledzenie kursora." @@ -1894,7 +1939,7 @@ msgstr "ustawiono wiersz nagłówka" msgid "heart" msgstr "serce" -#: internal/config/definitions.go:225 internal/reader/reader.go:237 +#: internal/config/definitions.go:225 internal/reader/reader.go:249 msgid "highlight tracking" msgstr "Śledzenie podświetlenia." @@ -2217,7 +2262,7 @@ msgstr "wiersz {0}, kolumna {1} do wiersza {2}, kolumny {3}" msgid "screen is empty" msgstr "Ekran jest pusty." -#: internal/reader/reader.go:155 +#: internal/reader/reader.go:158 msgid "screen {0}" msgstr "Ekran {0}" @@ -2365,7 +2410,7 @@ msgstr "zaskoczenie" msgid "tab" msgstr "tabulator" -#: internal/config/definitions.go:225 internal/reader/reader.go:234 +#: internal/config/definitions.go:225 internal/reader/reader.go:246 msgid "table mode" msgstr "tryb tabeli" @@ -2391,7 +2436,7 @@ msgid "unavailable" msgstr "niedostępne" #: internal/app/tutorial.go:107 internal/app/tutorial.go:161 -#: internal/reader/reader.go:249 internal/review/review.go:140 +#: internal/reader/reader.go:261 internal/review/review.go:140 msgid "unbound" msgstr "nieprzypisane" @@ -2423,7 +2468,7 @@ msgstr "mrugnięcie" msgid "{0} added to dictionary" msgstr "Dodano {0} do słownika" -#: internal/app/application_overrides.go:210 +#: internal/app/application_overrides.go:213 msgid "{0} application override selected" msgstr "Wybrano ustawienia aplikacji {0}" diff --git a/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po b/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po index dee3c616..9bd4fb28 100644 --- a/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po @@ -70,11 +70,11 @@ msgstr "Zangado" msgid "Apple" msgstr "Maçã" -#: internal/app/application_overrides.go:328 +#: internal/app/application_overrides.go:331 msgid "Application" msgstr "Aplicativo" -#: internal/app/application_overrides.go:327 +#: internal/app/application_overrides.go:330 msgid "Applications" msgstr "Aplicativos" @@ -171,7 +171,7 @@ msgstr "Borboleta" msgid "Canada" msgstr "Canadá" -#: internal/app/application_overrides.go:329 +#: internal/app/application_overrides.go:332 #: internal/preferences/settings.go:93 msgid "Cancel" msgstr "Cancelar" @@ -273,6 +273,43 @@ msgstr "Falha ao importar a área de transferência" msgid "Clipboard sync" msgstr "Sincronização da área de transferência" +#: internal/app/application_overrides.go:324 +msgid "Codex" +msgstr "Codex" + +#: internal/applicationoverrides/codex/codex.go:164 +msgid "Codex bridge error" +msgstr "Erro na ponte do Codex" + +#: internal/applicationoverrides/codex/codex.go:160 +msgid "Codex finished" +msgstr "Codex terminou" + +#: internal/applicationoverrides/codex/codex.go:162 +msgid "Codex interrupted" +msgstr "Codex interrompido" + +#: internal/applicationoverrides/codex/codex.go:152 +msgid "Codex ready" +msgstr "Codex pronto" + +#: internal/applicationoverrides/codex/codex.go:146 +msgid "Codex regions: 1 to 9 recent messages, newest first; 0 status" +msgstr "" +"Regiões do Codex: 1 a 9 mensagens recentes, a mais recente primeiro; 0 estado" + +#: internal/applicationoverrides/codex/codex.go:158 +msgid "Codex waiting for an answer" +msgstr "Codex aguarda uma resposta" + +#: internal/applicationoverrides/codex/codex.go:156 +msgid "Codex waiting for approval" +msgstr "Codex aguarda aprovação" + +#: internal/applicationoverrides/codex/codex.go:154 +msgid "Codex working" +msgstr "Codex está a trabalhar" + #: internal/emoji/catalog.go:68 msgid "Coffee" msgstr "Café" @@ -297,13 +334,13 @@ msgstr "Legal" msgid "Could not check spelling: {0}" msgstr "Não foi possível verificar a ortografia: {0}" -#: internal/app/application_overrides.go:281 -#: internal/app/application_overrides.go:84 +#: internal/app/application_overrides.go:284 +#: internal/app/application_overrides.go:85 msgid "Could not load application settings: {0}" msgstr "Não foi possível carregar as configurações do aplicativo: {0}" -#: internal/app/application_overrides.go:332 -#: internal/app/application_overrides.go:336 +#: internal/app/application_overrides.go:335 +#: internal/app/application_overrides.go:339 msgid "Could not open Applications: {0}" msgstr "Não foi possível abrir Aplicativos: {0}" @@ -320,8 +357,8 @@ msgstr "Não foi possível abrir o menu do Fenrir: {0}" msgid "Could not open Spellcheck Menu: {0}" msgstr "Não foi possível abrir o menu de ortografia: {0}" -#: internal/app/application_overrides.go:162 -#: internal/app/application_overrides.go:299 +#: internal/app/application_overrides.go:163 +#: internal/app/application_overrides.go:302 msgid "Could not restore application settings: {0}" msgstr "Não foi possível restaurar as configurações do aplicativo: {0}" @@ -350,12 +387,12 @@ msgstr "Adaga" msgid "Date format" msgstr "Formato da data" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Default" msgstr "Padrão" -#: internal/app/application_overrides.go:187 -#: internal/app/application_overrides.go:245 +#: internal/app/application_overrides.go:188 +#: internal/app/application_overrides.go:248 msgid "Default settings restored" msgstr "Configurações padrão restauradas" @@ -787,7 +824,7 @@ msgstr "Enjoado" msgid "Netherlands" msgstr "Países Baixos" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "NetworkManager" msgstr "NetworkManager" @@ -795,7 +832,7 @@ msgstr "NetworkManager" msgid "New channel" msgstr "Novo canal" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Ninjam" msgstr "Ninjam" @@ -811,6 +848,10 @@ msgstr "" "Regiões do Ninjam: 1 última mensagem do chat; 2 canal ou controle " "selecionado; 3 conexão, andamento e intervalo" +#: internal/applicationoverrides/codex/codex.go:139 +msgid "No Codex message received" +msgstr "Nenhuma mensagem do Codex recebida" + #: internal/applicationoverrides/ninjam/ninjam.go:79 msgid "No chat message received" msgstr "Nenhuma mensagem de chat recebida" @@ -1067,7 +1108,7 @@ msgstr "Anel" msgid "Rock on" msgstr "Gesto de rock" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Rogue" msgstr "Rogue" @@ -1167,8 +1208,8 @@ msgstr "Definir uma região dinâmica a partir de uma ou duas marcas" msgid "Set window begin and end marks" msgstr "Definir marcas de início e fim da janela" -#: internal/app/application_overrides.go:172 -#: internal/app/application_overrides.go:318 internal/app/commands.go:84 +#: internal/app/application_overrides.go:173 +#: internal/app/application_overrides.go:321 internal/app/commands.go:84 msgid "Settings are busy" msgstr "As configurações estão ocupadas" @@ -1446,6 +1487,10 @@ msgstr "Volume e panorama {0}" msgid "WTF?" msgstr "Mas que diabos?" +#: internal/applicationoverrides/codex/codex.go:149 +msgid "Waiting for Codex bridge" +msgstr "A aguardar a ponte do Codex" + #: internal/applicationoverrides/rogue/rogue.go:258 msgid "Wall" msgstr "Parede" @@ -1545,11 +1590,11 @@ msgstr "seta para a direita" msgid "at" msgstr "arroba" -#: internal/reader/reader.go:247 +#: internal/reader/reader.go:259 msgid "autoread disabled" msgstr "leitura automática desativada" -#: internal/reader/reader.go:245 +#: internal/reader/reader.go:257 msgid "autoread enabled" msgstr "leitura automática ativada" @@ -1700,7 +1745,7 @@ msgstr "linha atual" msgid "current word." msgstr "palavra atual." -#: internal/config/definitions.go:225 internal/reader/reader.go:240 +#: internal/config/definitions.go:225 internal/reader/reader.go:252 msgid "cursor tracking" msgstr "rastreamento do cursor" @@ -1901,7 +1946,7 @@ msgstr "linha de cabeçalho definida" msgid "heart" msgstr "coração" -#: internal/config/definitions.go:225 internal/reader/reader.go:237 +#: internal/config/definitions.go:225 internal/reader/reader.go:249 msgid "highlight tracking" msgstr "rastreamento em destaque" @@ -2229,7 +2274,7 @@ msgstr "linha {0}, coluna {1} até a linha {2}, coluna {3}" msgid "screen is empty" msgstr "a tela está vazia" -#: internal/reader/reader.go:155 +#: internal/reader/reader.go:158 msgid "screen {0}" msgstr "tela {0}" @@ -2378,7 +2423,7 @@ msgstr "surpreso" msgid "tab" msgstr "tabulação" -#: internal/config/definitions.go:225 internal/reader/reader.go:234 +#: internal/config/definitions.go:225 internal/reader/reader.go:246 msgid "table mode" msgstr "modo de tabela" @@ -2404,7 +2449,7 @@ msgid "unavailable" msgstr "indisponível" #: internal/app/tutorial.go:107 internal/app/tutorial.go:161 -#: internal/reader/reader.go:249 internal/review/review.go:140 +#: internal/reader/reader.go:261 internal/review/review.go:140 msgid "unbound" msgstr "sem atalho" @@ -2436,7 +2481,7 @@ msgstr "piscadela" msgid "{0} added to dictionary" msgstr "{0} adicionado ao dicionário" -#: internal/app/application_overrides.go:210 +#: internal/app/application_overrides.go:213 msgid "{0} application override selected" msgstr "Configurações do aplicativo {0} selecionadas" diff --git a/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po b/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po index 840833d7..74dcf1da 100644 --- a/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po @@ -69,11 +69,11 @@ msgstr "Злость" msgid "Apple" msgstr "Яблоко" -#: internal/app/application_overrides.go:328 +#: internal/app/application_overrides.go:331 msgid "Application" msgstr "Приложение" -#: internal/app/application_overrides.go:327 +#: internal/app/application_overrides.go:330 msgid "Applications" msgstr "Приложения" @@ -170,7 +170,7 @@ msgstr "Бабочка" msgid "Canada" msgstr "Канада" -#: internal/app/application_overrides.go:329 +#: internal/app/application_overrides.go:332 #: internal/preferences/settings.go:93 msgid "Cancel" msgstr "Отмена" @@ -272,6 +272,43 @@ msgstr "Не удалось импортировать буфер обмена" msgid "Clipboard sync" msgstr "Синхронизация буфера обмена" +#: internal/app/application_overrides.go:324 +msgid "Codex" +msgstr "Codex" + +#: internal/applicationoverrides/codex/codex.go:164 +msgid "Codex bridge error" +msgstr "Ошибка моста Codex" + +#: internal/applicationoverrides/codex/codex.go:160 +msgid "Codex finished" +msgstr "Codex завершил работу" + +#: internal/applicationoverrides/codex/codex.go:162 +msgid "Codex interrupted" +msgstr "Codex прерван" + +#: internal/applicationoverrides/codex/codex.go:152 +msgid "Codex ready" +msgstr "Codex готов" + +#: internal/applicationoverrides/codex/codex.go:146 +msgid "Codex regions: 1 to 9 recent messages, newest first; 0 status" +msgstr "" +"Области Codex: от 1 до 9 последние сообщения, сначала новые; 0 состояние" + +#: internal/applicationoverrides/codex/codex.go:158 +msgid "Codex waiting for an answer" +msgstr "Codex ждёт ответа" + +#: internal/applicationoverrides/codex/codex.go:156 +msgid "Codex waiting for approval" +msgstr "Codex ждёт подтверждения" + +#: internal/applicationoverrides/codex/codex.go:154 +msgid "Codex working" +msgstr "Codex работает" + #: internal/emoji/catalog.go:68 msgid "Coffee" msgstr "Кофе" @@ -296,13 +333,13 @@ msgstr "Круто" msgid "Could not check spelling: {0}" msgstr "Не удалось проверить правописание: {0}" -#: internal/app/application_overrides.go:281 -#: internal/app/application_overrides.go:84 +#: internal/app/application_overrides.go:284 +#: internal/app/application_overrides.go:85 msgid "Could not load application settings: {0}" msgstr "Не удалось загрузить настройки приложения: {0}" -#: internal/app/application_overrides.go:332 -#: internal/app/application_overrides.go:336 +#: internal/app/application_overrides.go:335 +#: internal/app/application_overrides.go:339 msgid "Could not open Applications: {0}" msgstr "Не удалось открыть меню приложений: {0}" @@ -319,8 +356,8 @@ msgstr "Не удалось открыть меню Fenrir: {0}" msgid "Could not open Spellcheck Menu: {0}" msgstr "Не удалось открыть меню проверки правописания: {0}" -#: internal/app/application_overrides.go:162 -#: internal/app/application_overrides.go:299 +#: internal/app/application_overrides.go:163 +#: internal/app/application_overrides.go:302 msgid "Could not restore application settings: {0}" msgstr "Не удалось восстановить настройки приложения: {0}" @@ -349,12 +386,12 @@ msgstr "Кинжал" msgid "Date format" msgstr "Формат даты" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Default" msgstr "По умолчанию" -#: internal/app/application_overrides.go:187 -#: internal/app/application_overrides.go:245 +#: internal/app/application_overrides.go:188 +#: internal/app/application_overrides.go:248 msgid "Default settings restored" msgstr "Настройки по умолчанию восстановлены" @@ -780,7 +817,7 @@ msgstr "Тошнота" msgid "Netherlands" msgstr "Нидерланды" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "NetworkManager" msgstr "NetworkManager" @@ -788,7 +825,7 @@ msgstr "NetworkManager" msgid "New channel" msgstr "Новый канал" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Ninjam" msgstr "Ninjam" @@ -804,6 +841,10 @@ msgstr "" "Области Ninjam: 1 последнее сообщение чата; 2 выбранный канал или элемент " "управления; 3 соединение, темп и интервал" +#: internal/applicationoverrides/codex/codex.go:139 +msgid "No Codex message received" +msgstr "Сообщений от Codex не получено" + #: internal/applicationoverrides/ninjam/ninjam.go:79 msgid "No chat message received" msgstr "Сообщения чата не получены" @@ -1059,7 +1100,7 @@ msgstr "Кольцо" msgid "Rock on" msgstr "Рокерский жест" -#: internal/app/application_overrides.go:321 +#: internal/app/application_overrides.go:324 msgid "Rogue" msgstr "Rogue" @@ -1159,8 +1200,8 @@ msgstr "Задать динамическую область по одной и msgid "Set window begin and end marks" msgstr "Установка начала и конца окна" -#: internal/app/application_overrides.go:172 -#: internal/app/application_overrides.go:318 internal/app/commands.go:84 +#: internal/app/application_overrides.go:173 +#: internal/app/application_overrides.go:321 internal/app/commands.go:84 msgid "Settings are busy" msgstr "Настройки заняты" @@ -1438,6 +1479,10 @@ msgstr "Громкость и панорама {0}" msgid "WTF?" msgstr "Какого чёрта?" +#: internal/applicationoverrides/codex/codex.go:149 +msgid "Waiting for Codex bridge" +msgstr "Ожидание моста Codex" + #: internal/applicationoverrides/rogue/rogue.go:258 msgid "Wall" msgstr "Стена" @@ -1537,11 +1582,11 @@ msgstr "стрелка вправо" msgid "at" msgstr "собака" -#: internal/reader/reader.go:247 +#: internal/reader/reader.go:259 msgid "autoread disabled" msgstr "Автоматическое чтение нового текста выключено" -#: internal/reader/reader.go:245 +#: internal/reader/reader.go:257 msgid "autoread enabled" msgstr "Автоматическое чтение нового текста включено" @@ -1691,7 +1736,7 @@ msgstr "Текущая строка." msgid "current word." msgstr "Текущее слово." -#: internal/config/definitions.go:225 internal/reader/reader.go:240 +#: internal/config/definitions.go:225 internal/reader/reader.go:252 msgid "cursor tracking" msgstr "Отслеживание курсора" @@ -1890,7 +1935,7 @@ msgstr "строка заголовков задана" msgid "heart" msgstr "сердце" -#: internal/config/definitions.go:225 internal/reader/reader.go:237 +#: internal/config/definitions.go:225 internal/reader/reader.go:249 msgid "highlight tracking" msgstr "Отслеживание выделения" @@ -2213,7 +2258,7 @@ msgstr "от строки {0}, столбца {1} до строки {2}, сто msgid "screen is empty" msgstr "Экран пустой." -#: internal/reader/reader.go:155 +#: internal/reader/reader.go:158 msgid "screen {0}" msgstr "Экран {0}" @@ -2362,7 +2407,7 @@ msgstr "удивление" msgid "tab" msgstr "табуляция" -#: internal/config/definitions.go:225 internal/reader/reader.go:234 +#: internal/config/definitions.go:225 internal/reader/reader.go:246 msgid "table mode" msgstr "режим таблицы" @@ -2388,7 +2433,7 @@ msgid "unavailable" msgstr "недоступно" #: internal/app/tutorial.go:107 internal/app/tutorial.go:161 -#: internal/reader/reader.go:249 internal/review/review.go:140 +#: internal/reader/reader.go:261 internal/review/review.go:140 msgid "unbound" msgstr "не назначено" @@ -2420,7 +2465,7 @@ msgstr "подмигивание" msgid "{0} added to dictionary" msgstr "{0} добавлено в словарь" -#: internal/app/application_overrides.go:210 +#: internal/app/application_overrides.go:213 msgid "{0} application override selected" msgstr "Выбраны настройки приложения {0}" diff --git a/internal/reader/application_feedback_test.go b/internal/reader/application_feedback_test.go new file mode 100644 index 00000000..607eebc1 --- /dev/null +++ b/internal/reader/application_feedback_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package reader + +import ( + "reflect" + "testing" + "time" + + "git.stormux.org/storm/fenrir/internal/applicationoverrides" + "git.stormux.org/storm/fenrir/internal/command" + "git.stormux.org/storm/fenrir/internal/presentation" + "git.stormux.org/storm/fenrir/internal/screen" +) + +func TestApplicationComposerCursorFeedback(t *testing.T) { + for _, mode := range []string{"left", "right", "status redraw", "leave review", "passive", "cursor off", "automatic off", "outside input", "edited input", "other adapter"} { + t.Run(mode, func(t *testing.T) { + r := New() + r.Configure_review(true, true, true, true) + before := fixture("Working", "› hello") + before.X, before.Y = 7, 1 + frame := applicationoverrides.Frame{Recognized: true, OwnsPresentation: true, InputCursor: true, Input: &screen.Region{Left: 2, Top: 1, Right: 19, Bottom: 1}} + r.Update_application(before, frame) + next := fixture("Working", "› hello") + next.X, next.Y = 6, 1 + want := []presentation.Message{presentation.Character_message('o')} + if mode == "right" { + before.X = 5 + r.Update_application(before, frame) + } + if mode == "status redraw" { + copy(next.Line(0), []rune("Reading file")) + } + if mode == "leave review" { + r.Command(command.CurrentLine) + } + if mode != "passive" { + r.Note_input(true, false, false) + } + switch mode { + case "passive": + want = nil + case "cursor off": + r.Configure(true, false) + want = nil + case "automatic off": + r.Configure(false, true) + want = nil + case "outside input": + next.Y = 0 + want = nil + case "edited input": + next.Line(1)[2] = 'X' + want = nil + case "other adapter": + frame.InputCursor = false + want = nil + } + if got := r.Update_application(next, frame); !reflect.DeepEqual(got, want) { + t.Fatal(mode, got, want) + } + if mode == "leave review" && r.review.Active() { + t.Fatal("direct navigation remained in detached review") + } + }) + } +} + +func TestApplicationActivityUsesSoundTimerWithoutScreenRedraws(t *testing.T) { + r := New() + r.Configure_progress(true) + r.Configure(false, false) // Working cues are independent of speech/automatic reading. + s := fixture("Working", "› ") + frame := applicationoverrides.Frame{Recognized: true, OwnsPresentation: true, Activity: true} + r.Update_application(s, frame) + now := time.Now() + for _, elapsed := range []time.Duration{0, 4 * time.Second, 8 * time.Second} { + if value, ok := r.Next_progress(now.Add(elapsed)); !ok || value != 50 { + t.Fatal(elapsed, value, ok) + } + if _, ok := r.Next_progress(now.Add(elapsed + 3999*time.Millisecond)); ok { + t.Fatal("activity flooded") + } + } + frame.Activity = false + r.Update_application(s, frame) + if r.Progress_active() { + t.Fatal("idle retained activity") + } + if _, ok := r.Next_progress(now.Add(12 * time.Second)); ok { + t.Fatal("idle beeped") + } + frame.Activity = true + r.Configure_progress(false) + r.Update_application(s, frame) + if _, ok := r.Next_progress(now.Add(16 * time.Second)); ok { + t.Fatal("progress toggle ignored") + } + r.Configure_progress(true) + r.Update_application(s, frame) + s.Active = false + r.Update_application(s, frame) + if _, ok := r.Next_progress(now.Add(20 * time.Second)); ok { + t.Fatal("inactive terminal beeped") + } +} diff --git a/internal/reader/application_override.go b/internal/reader/application_override.go index 4ed7551d..8bbd309d 100644 --- a/internal/reader/application_override.go +++ b/internal/reader/application_override.go @@ -4,10 +4,20 @@ package reader import ( + "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/screen" "slices" ) +// Cursor feedback requires unchanged composer text. Comparing masked snapshots +// allows simultaneous tool/status redraws outside the input field to stay quiet. +func input_cursor_feedback(previous, next screen.Snapshot) []presentation.Message { + if !slices.Equal(previous.Cells, next.Cells) { + return nil + } + return changes(previous, next, true) +} + // SessionState preserves shortcut-only state as well as configuration. Screen, // input timestamps and detached review positions are deliberately not restored. type SessionState struct { diff --git a/internal/reader/progress.go b/internal/reader/progress.go index f6280f5a..048527a4 100644 --- a/internal/reader/progress.go +++ b/internal/reader/progress.go @@ -32,10 +32,24 @@ func (r *Reader) Configure_progress(enabled bool) { } r.progressEnabled = enabled } -func (r *Reader) Next_progress(now time.Time) (float64, bool) { return r.progress.Next(now) } -func (r *Reader) Reset_progress() { r.progress.Reset() } +func (r *Reader) Next_progress(now time.Time) (float64, bool) { + if r.applicationActivity && r.progressEnabled { + // Use the existing spinner's fixed pitch, once every four seconds. This reports + // activity, not a guessed completion percentage, and needs no repaint. + if !r.applicationInputAt.IsZero() && now.Sub(r.applicationInputAt) < 300*time.Millisecond || + !r.applicationActivityAt.IsZero() && now.Sub(r.applicationActivityAt) < 4*time.Second { + return 0, false + } + r.applicationActivityAt = now + return 50, true + } + return r.progress.Next(now) +} +func (r *Reader) Reset_progress() { r.progress.Reset(); r.applicationActivity = false } -func (r *Reader) Progress_active() bool { return r.progress.Active() } +func (r *Reader) Progress_active() bool { + return r.applicationActivity && r.progressEnabled || r.progress.Active() +} func (r *Reader) Note_deletion() { r.progressDeletionAt = time.Now() } diff --git a/internal/reader/reader.go b/internal/reader/reader.go index d2c6261e..60c7236d 100644 --- a/internal/reader/reader.go +++ b/internal/reader/reader.go @@ -51,6 +51,8 @@ type Reader struct { lastCompletedWord string inputAt time.Time applicationInputAt time.Time + applicationActivity bool + applicationActivityAt time.Time attributeConfigured, attributeResumeMode string attributeMode, attributeFormat string lastAttribute screen.Attribute @@ -93,6 +95,7 @@ func (r *Reader) Update(next screen.Snapshot) []presentation.Message { func (r *Reader) Update_application(next screen.Snapshot, frame applicationoverrides.Frame) (output []presentation.Message) { r.completedWords = nil previous := r.screen + r.applicationActivity = next.Active && frame.Recognized && frame.OwnsPresentation && frame.Activity && r.progressEnabled // Python runs cursor-change commands only after recent direct application // input (300 ms). Reader commands and passive output must preserve review. if !r.keepReviewOnCursorChange && r.review.Active() && !r.applicationInputAt.IsZero() && time.Since(r.applicationInputAt) < 300*time.Millisecond && @@ -203,7 +206,16 @@ func (r *Reader) Update_application(next screen.Snapshot, frame applicationoverr return nil } if frame.OwnsPresentation { - return r.deletion_echo(deleted, next) + if len(deleted) > 0 { + return r.deletion_echo(deleted, next) + } + if frame.InputCursor && frame.Input != nil && r.cursorEcho && !r.review.Active() && + !r.applicationInputAt.IsZero() && time.Since(r.applicationInputAt) < 300*time.Millisecond && + frame.Input.Contains(previous.X, previous.Y, next.Cols) && frame.Input.Contains(next.X, next.Y, next.Cols) && + regions.allows(next.X, next.Y, next.Cols, true, true) { + return input_cursor_feedback(typingPrevious, typingNext) + } + return nil } readingPrevious := regions.filtered_snapshot(previous, true, true) readingNext := r.Automatic_snapshot() diff --git a/tests/native_codex.py b/tests/native_codex.py new file mode 100644 index 00000000..72525ab5 --- /dev/null +++ b/tests/native_codex.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# SPDX-License-Identifier: GPL-3.0-or-later +"""Private X11 bridge/PTY test, with fake speech and no live Codex requests.""" + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import tty + + +def fixture_shell(): + tty.setraw(0) + os.write(1, "\x1b[2J\x1b[HOpenAI Codex\x1b[22;1H› ".encode()) + while True: + data = os.read(0, 4096) + if not data: + return + os.write(1, data) + + +def main(): + from native_terminal import KeyboardFixture, SpeechServer, wait_for + if os.environ.get("FENRIR_PRIVATE_X11") != "1" or not os.environ.get("DISPLAY"): + raise SystemExit("Use xvfb-run -a env FENRIR_PRIVATE_X11=1") + repo = Path(__file__).resolve().parents[1] + spec = importlib.util.spec_from_file_location("bridge", repo / "tools/codex_bridge.py") + bridge = importlib.util.module_from_spec(spec) + spec.loader.exec_module(bridge) + with tempfile.TemporaryDirectory(prefix="fenrir-codex-native-") as directory: + root = Path(directory) + (root / "keyboard").mkdir() + (root / "keyboard/custom.toml").write_bytes((repo / "config/keyboard/desktop.toml").read_bytes()) + (root / "home").mkdir() + (root / "settings.toml").write_text('version=3\n[presentation.sound]\nenabled=false\n' + f'[remote]\nsocket_file="{root / "remote.sock"}"\n') + shell = root / "shell" + shell.write_text(f'#!{sys.executable}\nimport runpy, sys\nsys.argv=[{str(Path(__file__).resolve())!r}, "--shell"]\nrunpy.run_path(sys.argv[0], run_name="__main__")\n') + shell.chmod(0o700) + server = SpeechServer(root / "speech.sock") + keyboard = KeyboardFixture() + env = dict(os.environ, HOME=str(root / "home"), TMPDIR=str(root), + FENRIR_TEST_BINARY=os.environ.get("FENRIR_TEST_BINARY", str(repo / "fenrir")), + SPEECHD_ADDRESS="unix_socket:" + str(root / "speech.sock"), + LOGNAME="Username", USER="Username", LC_ALL="C", LANGUAGE="C") + process = subprocess.Popen(["xterm", "-geometry", "100x24", "-e", sys.executable, + str(repo / "tests/native_terminal.py"), "--outer", str(root)], env=env) + watcher = None + try: + wait_for(lambda: (root / "started").exists(), "reader startup") + state = json.loads((root / "started").read_text()) + subprocess.run(["xdotool", "windowfocus", "--sync", str(state["window"])], check=True) + wait_for(lambda: any("OpenAI Codex" in item for item in server.events), "fixture screen ready") + def spoken(text, start): + wait_for(lambda: any(text in item for item in server.events[start:]), f"speech {text!r}") + def action(chord, expected): + start = len(server.events) + keyboard.key(chord) + spoken(expected, start) + action("Insert+a", "Application Default") + action("c", "Codex") + action("Return", "Codex application override selected") + action("Insert+0", "Waiting for Codex bridge") + session = "fixture-session" + transcript = root / "session.jsonl" + def append_record(kind, payload): + with transcript.open("ab") as stream: + stream.write((json.dumps({"type": kind, "payload": payload}) + "\n").encode()) + append_record("session_meta", {"id": session}) + append_record("event_msg", {"type": "agent_message", "message": "Existing reply"}) + statusPath = root / "status.json" + bridge.write_status(statusPath, "working") + activePath = root / "active.json" + bridge.write_json(activePath, {"key":"fixture", "epoch":1}) + config = {"pid": os.getpid(), "started": bridge.process_info(os.getpid())[2], + "window": str(state["window"]), "terminal": 1, "session": session, + "transcript": str(transcript), "status_path": str(statusPath), "lock_path": str(root / "watch.lock"), + "active_path": str(activePath), "key":"fixture"} + watcher = subprocess.Popen([sys.executable, str(repo / "tools/codex_bridge.py"), "--watch", json.dumps(config)], env=env) + wait_for(lambda: (root / "watch.lock").exists(), "bridge startup") + # Wait for baseline delivery by polling the manual history command. + start = len(server.events) + for _ in range(20): + keyboard.key("Insert+1") + time.sleep(0.2) + if "Existing reply" in server.events[start:]: + break + else: + raise AssertionError("baseline history unavailable") + action("Insert+0", "Codex working") + # The raw fixture echoes terminal cursor escapes just like a TUI. + # Actual XTest arrows must speak the unchanged composer character. + keyboard.key("h", "e", "l", "l", "o") + action("Left", "o") + action("Left", "l") + action("Right", "o") + start = len(server.events) + append_record("event_msg", {"type": "item_completed", "item": {"type": "CommandExecution", "aggregated_output": "Private tool result"}}) + message = json.dumps({"type": "event_msg", "payload": {"type": "item_completed", "item": {"type": "AgentMessage", "content": [{"type": "Text", "text": "A completed progress update."}]}}}) + "\n" + with transcript.open("ab") as stream: + stream.write(message[:-3].encode()) + time.sleep(0.5) + assert not any("completed progress" in item or "Private tool result" in item for item in server.events[start:]) + with transcript.open("ab") as stream: + stream.write(message[-3:].encode()) + spoken("A completed progress update.", start) + time.sleep(2.3) # Include a complete heartbeat/retry interval. + assert server.events[start:].count("A completed progress update.") == 1, server.events[start:] + action("Insert+1", "A completed progress update.") + action("Insert+2", "Existing reply") + start = len(server.events) + longReply = "Long reply " + "word " * 2000 + append_record("event_msg", {"type": "agent_message", "message": longReply}) + spoken("Long reply", start) + assert any(item.strip() == longReply.strip() for item in server.events[start:]), "long reply truncated" + watcher.terminate() + watcher.wait(timeout=3) + bridge.write_status(statusPath, "working") + start = len(server.events) + watcher = subprocess.Popen([sys.executable, str(repo / "tools/codex_bridge.py"), "--watch", json.dumps(config)], env=env) + time.sleep(0.6) + assert not any("Long reply" in item for item in server.events[start:]), "replay spoke old history" + bridge.write_status(statusPath, "done") + time.sleep(0.4) + action("Insert+0", "Codex finished") + action("Insert+a", "Application Codex") + action("d", "Default") + action("Return", "Default settings restored") + keyboard.key("Insert+q") + wait_for(lambda: (root / "finished").exists(), "reader exit") + assert not server.errors, server.errors + print("PASS: real private PTY/override/socket bridge, composer Left/Right feedback, baseline history, completed messages only, one announcement, long replies, replay and status") + except BaseException: + print("Recent private speech:", server.events[-15:], file=sys.stderr) + if (root / "status.json").exists(): + print("Bridge status:", (root / "status.json").read_text(), file=sys.stderr) + if (root / "reader-errors").exists(): + print("Reader errors:", (root / "reader-errors").read_text()[-2500:], file=sys.stderr) + raise + finally: + if watcher is not None and watcher.poll() is None: + watcher.terminate() + watcher.wait(timeout=3) + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + keyboard.close() + server.close() + + +if __name__ == "__main__": + fixture_shell() if "--shell" in sys.argv else main() diff --git a/tests/test_codex_bridge.py b/tests/test_codex_bridge.py new file mode 100644 index 00000000..8806fe02 --- /dev/null +++ b/tests/test_codex_bridge.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Private bridge fixtures: python3 -m unittest discover -s tests -p test_codex_bridge.py.""" + +import importlib.util +import io +import json +from pathlib import Path +import socket +import os +import tempfile +import threading +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("codex_bridge", Path(__file__).resolve().parents[1] / "tools/codex_bridge.py") +bridge = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bridge) + + +def record(kind, payload): + return (json.dumps({"type": kind, "payload": payload}) + "\n").encode() + + +class BridgeTests(unittest.TestCase): + def test_completed_messages_only_and_partial_records(self): + meta = record("session_meta", {"id": "fixture-session"}) + tool = record("event_msg", {"type": "item_completed", "item": {"type": "CommandExecution", "aggregated_output": "DO NOT READ"}}) + reasoning = record("response_item", {"type": "reasoning", "text": "DO NOT READ"}) + message = record("event_msg", {"type": "item_completed", "item": {"type": "AgentMessage", "content": [{"type": "Text", "text": "Complete reply."}]}}) + stream = io.BytesIO(meta + tool + reasoning + message[:-2]) + follower = bridge.Follower("fixture-session") + follower.read_available(stream) + self.assertFalse(follower.history) + stream.seek(0, io.SEEK_END) + stream.write(message[-2:]) + follower.read_available(stream) + self.assertEqual([entry[1] for entry in follower.history], ["Complete reply."]) + follower.read_available(stream) + self.assertEqual(len(follower.history), 1) + + def test_baseline_bounded_history_and_session_isolation(self): + data = record("session_meta", {"id": "fixture-session"}) + for index in range(15): + data += record("event_msg", {"type": "agent_message", "message": f"Reply {index}"}) + follower = bridge.Follower("fixture-session") + follower.read_available(io.BytesIO(data), baseline=True) + self.assertEqual(len(follower.history), 9) + self.assertTrue(all(entry[2] for entry in follower.history)) + self.assertEqual(follower.history[-1][1], "Reply 14") + with self.assertRaises(ValueError): + bridge.Follower("another-session").read_available(io.BytesIO(data)) + + def test_large_unicode_messages_fit_protocol_without_truncation(self): + text = 'Text 界 "\n\t\\' * 9000 + base = {"version": 1, "window": "42", "terminal": 1, "session": "fixture-session"} + events = bridge.message_events(base, ["message-id", text, False]) + self.assertEqual("".join(event["text"] for event in events), text) + self.assertGreater(len(events), 1) + self.assertTrue(all(len(("APPLICATION CODEX " + json.dumps(event, ensure_ascii=False) + "\n").encode()) <= 8129 for event in events)) + + def test_socket_delivery_and_window_routing(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + socketPath = root / f"fenrirscreenreader-{os.getpid()}.sock" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(str(socketPath)) + server.listen() + entry = {"pid": os.getpid(), "updated_at": bridge.time.time(), "screen_driver": "ptyDriver", "x11_window_id": "42", "socket_files": [str(socketPath)]} + (root / "instance.json").write_text(json.dumps(entry)) + self.assertEqual(list(bridge.reader_sockets({"window": "43"}, root)), []) + destinations = list(bridge.reader_sockets({"window": "42"}, root)) + self.assertEqual(len(destinations), 1) + received = [] + def receive(): + connection, _ = server.accept() + with connection: + data = b"" + while chunk := connection.recv(8192): + data += chunk + received.append(data) + connection.sendall(b"\n") + thread = threading.Thread(target=receive) + thread.start() + self.assertTrue(bridge.send_event(destinations[0], {"kind": "status", "status": "working"})) + thread.join(timeout=2) + self.assertTrue(received[0].startswith(b"APPLICATION CODEX ")) + + def test_question_status_and_neutral_hook(self): + with tempfile.TemporaryDirectory() as directory, patch.object(bridge, "codex_process", return_value={"pid": 123, "started": "1", "tty": 0}), patch.object(bridge, "private_directory", return_value=Path(directory)), patch.dict(os.environ, {"WINDOWID": "42"}): + bridge.hook({"hook_event_name": "Stop", "session_id": "fixture-session", "transcript_path": "/unused", "last_assistant_message": "\nI have a question.\nContinue?"}) + files = list(Path(directory).glob("*.json")) + self.assertEqual(json.loads(files[0].read_text()), {"status": "question"}) + self.assertEqual(files[0].stat().st_mode & 0o777, 0o600) + + def test_question_record_completion_and_attribution_filter(self): + follower = bridge.Follower("fixture-session") + data = record("session_meta", {"id": "fixture-session"}) + data += record("event_msg", {"type": "agent_message", "message": "I have a question.\nContinue?\nmetadata"}) + data += record("event_msg", {"type": "task_complete"}) + follower.read_available(io.BytesIO(data)) + self.assertEqual(follower.status, "question") + self.assertNotIn("metadata", follower.history[-1][1]) + + def test_neutral_main_output_on_hook_failure(self): + output = io.StringIO() + with patch.object(bridge.sys, "argv", ["codex_bridge.py"]), patch.object(bridge.sys, "stdin", io.StringIO('{"hook_event_name":"Stop"}')), patch.object(bridge.sys, "stdout", output), patch.object(bridge.sys, "stderr", io.StringIO()): + bridge.main() + self.assertEqual(json.loads(output.getvalue()), {}) + + def test_conversation_switch_retires_old_terminal_binding(self): + with tempfile.TemporaryDirectory() as directory, patch.object(bridge, "codex_process", return_value={"pid": 123, "started": "1", "tty": 0}), patch.object(bridge, "private_directory", return_value=Path(directory)), patch.object(bridge.subprocess, "Popen"), patch.dict(os.environ, {"WINDOWID": "42"}): + payload = {"hook_event_name":"SessionStart", "session_id":"first-session", "transcript_path":"/unused"} + bridge.hook(payload) + activePath = next(Path(directory).glob("active-*.json")) + first = json.loads(activePath.read_text()) + bridge.hook(payload) + self.assertEqual(json.loads(activePath.read_text()), first) + bridge.hook(dict(payload, session_id="second-session")) + second = json.loads(activePath.read_text()) + self.assertNotEqual(second["key"], first["key"]) + self.assertGreater(second["epoch"], first["epoch"]) + bridge.hook(dict(payload, hook_event_name="Stop")) + self.assertEqual(json.loads(activePath.read_text()), second) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/codex_bridge.py b/tools/codex_bridge.py new file mode 100644 index 00000000..b31fe515 --- /dev/null +++ b/tools/codex_bridge.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Opt-in Codex hooks: relay completed messages to a selected Fenrir override. + +No speech, model feedback, tool output, or transcript copies are produced. A +single detached follower per conversation reads the transcript supplied by +Codex and keeps at most nine completed assistant messages in memory. +""" + +import collections +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import socket +import stat +import subprocess +import sys +import tempfile +import time + +MAX_MESSAGE = 1024 * 1024 +MAX_LINE = 4 * MAX_MESSAGE +STATUSES = { + "SessionStart": "idle", "UserPromptSubmit": "working", + "PermissionRequest": "permission", "PostToolUse": "working", + "Stop": "done", "Interrupt": "interrupted", "SessionEnd": "closed", +} + + +def process_info(pid): + # The process name may itself contain spaces or parentheses. + fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() + return int(fields[1]), int(fields[4]), fields[19] + + +def codex_process(): + pid = os.getppid() + while pid > 1: + parent, tty, started = process_info(pid) + executable = os.path.basename(os.readlink(f"/proc/{pid}/exe")) + if executable == "codex": + return {"pid": pid, "started": started, "tty": tty} + pid = parent + raise ValueError("no Codex ancestor; run this command as a Codex hook") + + +def private_directory(): + runtimeRoot = Path(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir())) + directory = runtimeRoot / f"fenrir-codex-{os.getuid()}" + directory.mkdir(mode=0o700, exist_ok=True) + info = directory.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: + raise ValueError("unsafe bridge runtime directory") + return directory + + +def write_status(path, status): + write_json(path, {"status": status}) + + +def write_json(path, data): + descriptor, temporary = tempfile.mkstemp(prefix=".status-", dir=path.parent) + try: + with os.fdopen(descriptor, "w") as stream: + json.dump(data, stream) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def hook(payload): + eventName = payload.get("hook_event_name") + if eventName not in STATUSES: + return + session = payload.get("session_id", "") + transcript = payload.get("transcript_path") + if not isinstance(session, str) or not session or not isinstance(transcript, str): + raise ValueError("Codex hook did not supply a session and transcript") + owner = codex_process() + window = os.environ.get("WINDOWID", "") + # Linux virtual consoles use major 4, minor 1..63. Never guess a VT from a PTY. + terminal = 1 if window else os.minor(owner["tty"]) + if not window and (os.major(owner["tty"]) != 4 or not 1 <= terminal <= 63): + raise ValueError("no X11 window or Linux console identifies this session") + directory = private_directory() + key = hashlib.sha256(f"{session}:{owner['pid']}:{owner['started']}".encode()).hexdigest() + statusPath = directory / f"{key}.json" + status = STATUSES[eventName] + if eventName == "Stop": + lines = str(payload.get("last_assistant_message") or "").lstrip().splitlines() + if lines and lines[0].strip() == "I have a question.": + status = "question" + write_status(statusPath, status) + if eventName not in ("SessionStart", "UserPromptSubmit"): + return + terminalKey = hashlib.sha256(f"{window}:{terminal}".encode()).hexdigest() + activePath = directory / f"active-{terminalKey}.json" + descriptor = os.open(str(activePath) + ".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + active = json.loads(activePath.read_text()) if activePath.exists() else {} + if active.get("key") != key: + write_json(activePath, {"key": key, "epoch": max(time.time_ns(), active.get("epoch", 0) + 1)}) + config = dict(owner, window=window, terminal=terminal, session=session, + transcript=transcript, status_path=str(statusPath), + lock_path=str(directory / f"{key}.lock"), active_path=str(activePath), key=key) + subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--watch", json.dumps(config)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True) + + +def completed_message(record, offset): + if record.get("type") != "event_msg": + return None + payload = record.get("payload", {}) + if payload.get("type") == "agent_message": + text = payload.get("message") + return (str(offset), clean_message(text)) if isinstance(text, str) and text.strip() else None + if payload.get("type") == "item_completed": + item = payload.get("item", {}) + if item.get("type") == "AgentMessage": + text = "".join(part.get("text", "") for part in item.get("content", []) + if part.get("type") == "Text") + return (str(offset), clean_message(text)) if text.strip() else None + return None + + +def clean_message(text): + # Codex appends machine-readable memory attribution to some replies. + return re.sub(r".*?", "", text, flags=re.DOTALL).strip() + + +def record_status(record): + if record.get("type") != "event_msg": + return None + return {"task_started": "working", "task_complete": "done", + "turn_aborted": "interrupted"}.get(record.get("payload", {}).get("type")) + + +def reader_sockets(config, registry=None): + if registry is None: + registry = Path(tempfile.gettempdir()) / "fenrirscreenreader-instances" + for path in registry.glob("*.json"): + try: + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid not in (0, os.getuid()): + continue + if info.st_size > 16384: + continue + data = json.loads(path.read_text()) + if time.time() - data["updated_at"] > 30: + continue + if config["window"]: + matches = (data["screen_driver"] == "ptyDriver" + and data.get("x11_window_id") == config["window"] + and info.st_uid == os.getuid()) + else: + matches = data["screen_driver"] == "vcsaDriver" + if not matches: + continue + try: + os.kill(data["pid"], 0) + except PermissionError: + pass # A user may connect to the root console reader. + for name in data["socket_files"]: + # An explicit socket_file is also private. Never use the shared + # router: it may belong to another window or console reader. + if Path(name).is_absolute() and Path(name).name != "fenrirscreenreader-daemon.sock": + socketInfo = os.lstat(name) + if stat.S_ISSOCK(socketInfo.st_mode) and socketInfo.st_uid == info.st_uid: + yield name, data["pid"] + except (OSError, ValueError, KeyError, TypeError): + continue + + +def send_event(destination, event): + path, expectedPID = destination + encoded = ("APPLICATION CODEX " + json.dumps(event, ensure_ascii=False) + "\n").encode() + if len(encoded) > 8129: + raise ValueError("bridge packet exceeds Fenrir protocol limit") + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(0.5) + connection.connect(path) + import struct + peerPID, _, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + if peerPID != expectedPID: + return False + connection.sendall(encoded) + connection.shutdown(socket.SHUT_WR) + return connection.recv(1024).strip() == b"" + + +def message_events(base, message): + identifier, text, baseline = message + # Bound JSON expansion as well as UTF-8 length (including control characters). + chunks, current, size = [], [], 0 + for char in text: + charSize = len(json.dumps(char, ensure_ascii=False).encode()) - 2 + if size + charSize > 5000 and current: + chunks.append("".join(current)) + current, size = [], 0 + current.append(char) + size += charSize + if current: + chunks.append("".join(current)) + return [dict(base, kind="message", id=identifier, text=chunk, part=index, + parts=len(chunks), baseline=baseline) for index, chunk in enumerate(chunks)] + + +class Follower: + def __init__(self, session): + self.session = session + self.offset = 0 + self.history = collections.deque(maxlen=9) + self.status = "idle" + self.verified = False + + def read_available(self, stream, baseline=False): + stream.seek(self.offset) + while True: + offset = stream.tell() + line = stream.readline(MAX_LINE + 1) + if len(line) > MAX_LINE: + raise ValueError("Codex transcript record exceeds 4 MiB") + if not line or not line.endswith(b"\n"): + return # A partial JSON record is not a completed message. + self.offset = stream.tell() + record = json.loads(line) + if not self.verified: + if record.get("type") != "session_meta" or record.get("payload", {}).get("id") != self.session: + raise ValueError("Codex transcript belongs to another session") + self.verified = True + continue + message = completed_message(record, offset) + if message: + if len(message[1].encode()) > MAX_MESSAGE: + raise ValueError("Codex message exceeds 1 MiB") + self.history.append([message[0], message[1], baseline]) + status = record_status(record) + if status: + if status == "done" and self.history and self.history[-1][1].splitlines()[0].strip() == "I have a question.": + status = "question" + self.status = status + + +def watch(config): + descriptor = os.open(config["lock_path"], os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + follower = Follower(config["session"]) + base = {key: config[key] for key in ("window", "terminal", "session")} + base["version"] = 1 + active = json.loads(Path(config["active_path"]).read_text()) + if active.get("key") != config["key"]: + return + base["epoch"] = active["epoch"] + statusStamp, lastSend, lastState = None, 0, None + stream = None + try: + while True: + if json.loads(Path(config["active_path"]).read_text()).get("key") != config["key"]: + break # Another conversation now owns this terminal. + alive = False + try: + alive = process_info(config["pid"])[2] == config["started"] + except OSError: + pass + if stream is None and alive: + try: + descriptor = os.open(config["transcript"], os.O_RDONLY | os.O_NOFOLLOW) + stream = os.fdopen(descriptor, "rb") + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid(): + raise ValueError("unsafe Codex transcript") + follower.read_available(stream, baseline=True) + except FileNotFoundError: + pass # SessionStart may precede the first durable record. + elif stream is not None: + follower.read_available(stream) + statusPath = Path(config["status_path"]) + stamp = statusPath.stat().st_mtime_ns + if stamp != statusStamp: + follower.status = json.loads(statusPath.read_text())["status"] + statusStamp = stamp + if not alive: + follower.status = "closed" + state = (follower.status, tuple((message[0], message[2]) for message in follower.history)) + if state != lastState or time.monotonic() - lastSend >= 2: + for destination in reader_sockets(config): + try: + if not send_event(destination, dict(base, kind="status", status=follower.status)): + continue + success = True + for message in follower.history: + for event in message_events(base, message): + if not send_event(destination, event): + success = False + break + if not success: + break + if success: + for message in follower.history: + message[2] = True + except (OSError, ValueError): + continue + lastState, lastSend = state, time.monotonic() + if not alive or follower.status == "closed": + break + time.sleep(0.2) + finally: + if stream is not None: + stream.close() + # Keep the lock inode stable: another watcher may already have it open. + # Runtime state contains status only and is overwritten on the next hook. + + +def main(): + if len(sys.argv) == 3 and sys.argv[1] == "--watch": + config = json.loads(sys.argv[2]) + try: + watch(config) + except (OSError, ValueError, KeyError, TypeError): + write_status(Path(config["status_path"]), "error") + event = {key: config[key] for key in ("window", "terminal", "session")} + active = json.loads(Path(config["active_path"]).read_text()) + if active.get("key") != config["key"]: + return + event.update(version=1, epoch=active["epoch"], kind="status", status="error") + for destination in reader_sockets(config): + try: + send_event(destination, event) + except OSError: + pass + return + try: + hook(json.load(sys.stdin)) + except (OSError, ValueError, KeyError, TypeError) as error: + print(f"Fenrir Codex bridge: {error}", file=sys.stderr) + # Neutral output: no context injection, approval decisions, or continuation. + print("{}") + + +if __name__ == "__main__": + main()