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