170 lines
5.9 KiB
Go
170 lines
5.9 KiB
Go
// 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
|
||
}
|