Codex application override added.

This commit is contained in:
Storm Dragon committed 2026-10-28 20:46:12 -04:00
1 parent b887f6ea95
commit 7c53cb39d4
23 files changed
+1771 -155

No files matched your search

@@ -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
}
@@ -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")
}
}