Files

170 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}