Files
correx/apps/tui-go/internal/app/model.go
T
kami 5ed8ebd0c5 fix(tui): render CoT once per turn, stop clipping tool output (#415, #414)
The reasoning trace was rendered twice: once as its own "thinking" row on
inference.completed, and again as a ✼ block on the following tool row, whose
Reasoning was copied from Model.lastReasoning. The fallback branch was worse —
lastReasoning is current model state, so every tool row with no Reasoning of its
own (all non-diff rows, all snapshot-restored rows) got the newest trace stamped
on it retroactively. Drop the block, the copy, and the now-dead
RouterEntry.Reasoning / Model.lastReasoning; the standalone row already covers
tool turns.

actionToolText clipped every summary to 48 columns, which cut tool output and —
worse — harness coach text: read-before-write rejections, write blocks, gate
feedback, exactly the messages that say whether the agent was steered or
silently blocked. The action renderer already wraps to panel width, so the clip
was the only single-line ceiling; raise it to 4000 chars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:46:25 +04:00

654 lines
20 KiB
Go

package app
import (
"strconv"
"strings"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
// DisplayState is the top-level screen the UI renders.
type DisplayState int
const (
StateIdle DisplayState = iota
StateInSession
StateApproval
StateClarification
StateWorkflowPropose
)
// InputMode toggles what a submitted line targets.
type InputMode int
const (
ModeRouter InputMode = iota // chat / steering input
ModeFilter // session-list filter
ModeIntent // freeform request for a workflow being started
)
// EditMode is the vim-style modality: Normal = bare-key commands, Insert = typing.
type EditMode int
const (
ModeNormal EditMode = iota
ModeInsert
)
// ChatMode selects how a turn is sent to the router.
const (
ChatModeChat = "CHAT"
ChatModeSteering = "STEERING"
)
// OverlayKind is the active modal (immediate-mode: drawn on top when set).
type OverlayKind int
const (
OverlayNone OverlayKind = iota
OverlayPalette
OverlayEventInspector
OverlayDiff
OverlayToolPalette
OverlayModels
OverlayArtifacts
OverlayConfig
OverlayStats
OverlayIdeas
OverlayHealth
OverlaySessions
OverlayTasks
OverlayFiles
OverlayStatusbar
OverlayGrants
OverlayGrantScope
OverlayHelp
OverlayProjectProfile
OverlayOperatorProfile
OverlayPlan
)
// RouterEntry is one line in a session's conversation transcript.
type RouterEntry struct {
Role string // user | router | tool | narration | narration_llm | action
Content string
Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟)
Metrics *TurnMetrics
}
// TurnMetrics carries optional latency + token cost for a ROUTER chat turn.
type TurnMetrics struct {
LatencyMs int64
TotalTokens int
}
// EventEntry is a row in the event stream.
type EventEntry struct {
Time string
Type string
Detail string
}
// PlanStageStatus tracks a stage's lifecycle in a locked execution plan.
type PlanStageStatus int
const (
PlanPending PlanStageStatus = iota
PlanRunning
PlanCompleted
PlanFailed
)
// PlanStage is one stage in a locked execution plan, with live execution status.
type PlanStage struct {
ID string
Status PlanStageStatus
}
// ToolStatus mirrors the Kotlin ToolDisplayStatus.
type ToolStatus int
const (
ToolStarted ToolStatus = iota
ToolCompleted
ToolFailed
ToolRejected
)
type ToolRecord struct {
Name string
Tier int
Status ToolStatus
Params []string // pretty "key=value" call args, from tool.started
}
// ManifestTool is a declared (not-yet-run) tool from a stage manifest.
type ManifestTool struct {
Name string
Tier int
}
// ProfileField is one editable row in the project/operator profile editors — a flat
// key/value text field, mirroring the config editor's STRING-field editing model.
type ProfileField struct {
Key string
Value string
}
// Approval is a pending approval gate for a session.
type Approval struct {
RequestID string
SessionID string
Tier string
Risk string
ToolName string
Preview string
// Rationale holds the plane-2 verified preconditions ("[PATH_OUTSIDE_WORKSPACE] …")
// that justify the gate — shown in the approval band instead of an opaque tier.
Rationale []string
}
// tierNum parses the numeric part of a tier label ("T3" → 3). Unparseable or
// empty tiers return -1, which is treated as low-risk (never requires confirm).
func tierNum(tier string) int {
t := strings.TrimSpace(strings.ToUpper(tier))
t = strings.TrimPrefix(t, "T")
if t == "" {
return -1
}
n, err := strconv.Atoi(t)
if err != nil {
return -1
}
return n
}
// HighTier reports whether this gate is destructive/high-risk (T3+), so an approve
// must be confirmed with a second keypress rather than acting on one keystroke.
func (a *Approval) HighTier() bool {
return tierNum(a.Tier) >= 3
}
// ClarQuestion is one open question a stage raised (mirrors the wire DTO).
type ClarQuestion struct {
ID string
Prompt string
Header string
Options []string
MultiSelect bool
}
// Clarification is a stage's pending set of open questions for the operator.
type Clarification struct {
RequestID string
SessionID string
StageID string
Questions []ClarQuestion
}
// ProposeCandidate is one workflow the router suggests (mirrors the wire DTO).
type ProposeCandidate struct {
WorkflowID string
Reason string
}
// Proposal is the router's triage suggestion of candidate workflows for a chat
// request, rendered as a choice panel with a manual-answer slot. Picking a candidate
// launches that workflow; the custom slot continues the conversation.
type Proposal struct {
ProposalID string
SessionID string
Prompt string
OriginalRequest string
Candidates []ProposeCandidate
}
// Session is the UI's view of one server session.
type Session struct {
ID string
Status string
WorkflowID string
Name string
named bool // Name came from a session.renamed (intent-derived title); don't clobber with workflowId
LastEventAt int64
CurrentStage string
PlanGoal string
PlanStages []PlanStage
WorkspaceRoot string // bound cwd, from session.workspace_bound
LastOutput string
LastResponse string
Tools []ToolRecord
ToolsByStage map[string][]ManifestTool
TokenBudgetByStage map[string]int // stage -> ceiling, from stage.tool_manifest
StageTokensUsed int // tokens used by the most recent inference in CurrentStage
Events []EventEntry
// Pending is the *current* approval gate shown in the band. It always mirrors
// PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can
// keep reading a single pointer while multiple gates queue up behind it.
Pending *Approval
PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓
PendingIdx int // selected index into PendingQueue
Clar *Clarification // open questions awaiting answers (clarification view)
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
Active bool // an inference/tool is in flight (drives the spinner)
// LastReview is the most recent semantic reviewer (Gate 3) verdict for this
// session, from review.findings. Nil until the first review lands.
LastReview *ReviewResult
}
// ReviewResult is the semantic reviewer's verdict + findings over a stage's
// produced files (mirrors ReviewFindingsRaisedEvent).
type ReviewResult struct {
StageID string
Verdict string // PASS | WARN | FAIL
Findings []ReviewFinding
Blocked bool
}
type ReviewFinding struct {
Severity string
Confidence float64
Category string
Target string
Message string
SuggestedFix string
Correctness bool
}
// Workflow is a launchable workflow advertised by the server.
type Workflow struct {
ID string
Description string
}
// Model is the whole TUI state. View is a pure function of it (immediate-mode),
// which is what keeps overlays from desyncing.
type Model struct {
width, height int
client *ws.Client
theme Theme
quitting bool
// connection
connected bool
reconnecting bool
// sessions
sessions []Session
selectedID string
filter string
workflows []Workflow
wfIndex int // -1 = not in workflow picker
wfVisible bool
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
wfPendingName string
// launcher (idle screen): the active "what to launch" selection — 0 = chat, 1..N =
// workflows[i-1] — cycled with Tab and shown at the input's lower-right. railHidden
// folds away the idle status/quick-keys rail.
launcherWf int
railHidden bool
bgUpdates int
// input
editMode EditMode
inputMode InputMode
inputBuffer string
inputCursor int
inputHistory []string // submitted lines across all contexts (chat, intent, in-session), newest last
historyIndex int // -1 = editing the live buffer; else an index into inputHistory
savedBuffer string // live buffer stashed while walking history
// flow flags
sessionEntered bool
approvalDismissed bool
pendingWorkflowFocus bool
// router transcript
routerMessages map[string][]RouterEntry
routerConnected bool
chatMode string
// transcriptSel is the index (into the selected session's transcript) of the message
// the operator has navigated to with ctrl+↑/↓; -1 = no selection (tail-follow). A
// selected message is highlighted, scrolled into view, and is what `y` copies.
transcriptSel int
copiedFlash int // frame at which a copy happened, for a brief "copied" footer note
// provider
currentModel string
providerType string // LOCAL | REMOTE
// managed-model swap (nil resource fields = unavailable)
availableModels []string
modelsIndex int
gpuUsedMB *int64
gpuTotalMB *int64
gpuUtil *int
ramMB *int64
sysRamUsedMB *int64
sysRamTotalMB *int64
// diff / overlay
overlay OverlayKind
overlayEventIdx int
diffScrollOffset int
modalScroll int // body scroll for tall fixed-content modals (help, stats)
eventStripShown bool
// in-session right panel: 0 = events, 1 = changes (token usage + changed files),
// 2 = off (output full-width). Cycled with `d`.
rightPanel int
// artifact viewer (OverlayArtifacts) — populated by the artifact.list response
artifacts []protocol.ArtifactDto
artifactsFor string // sessionId the current listing belongs to
artifactsIndex int
artifactScroll int
artifactsLoading bool
// config editor (OverlayConfig) — populated by the config.snapshot response
configFields []protocol.ConfigFieldDto
configIndex int
configStaged map[string]string // key -> edited value, pending save
configEditing bool // true while typing a value into configEditBuf
configEditBuf string
configError string
configRestart []string // keys from the last save that need a restart
configLoading bool
// project profile editor (OverlayProjectProfile) — populated by project_profile.snapshot;
// mirrors the config editor's field-list/staged-edit pattern. fields[0] is "about",
// fields[1] is "conventions" (semicolon-joined), the rest are one row per command.
projectProfileFields []ProfileField
projectProfileIndex int
projectProfileStaged map[string]string
projectProfileEditing bool
projectProfileEditBuf string
projectProfileError string
projectProfileLoading bool
// operator profile editor (OverlayOperatorProfile) — populated by operator_profile.snapshot.
// fields are "about", "approvalMode", "preferredModels" (comma-joined), "conventions"
// (semicolon-joined). proposedAdaptation is read-only (from ProfileAdaptationService).
operatorProfileFields []ProfileField
operatorProfileIndex int
operatorProfileStaged map[string]string
operatorProfileEditing bool
operatorProfileEditBuf string
operatorProfileError string
operatorProfileLoading bool
operatorProfileProposed string
// session stats (OverlayStats) — populated by the session.stats reply
stats *protocol.StatsDto
statsFor string // sessionId the current stats belong to
statsLoading bool
// idea board (OverlayIdeas) — cross-session, populated by the idea.list reply
ideas []protocol.IdeaDto
ideasIndex int
ideasLoading bool
// health checks (OverlayHealth) — system-scoped, populated by the health.checks reply
health *protocol.HealthDto
healthLoading bool
// session browser (OverlaySessions) — recent sessions fetched over HTTP from
// GET /sessions, so prior runs are reachable after a server restart + TUI reopen
// (the WS stream only carries live sessions, not the historical roster).
sessionList []SessionSummary
sessionListIndex int
sessionListLoading bool
sessionListErr string
// task board (OverlayTasks) — all tasks across projects, fetched over HTTP from
// GET /tasks. taskDetail toggles the per-task detail pane (built from the list payload).
taskList []TaskSummary
taskListIndex int
taskListLoading bool
taskListErr string
taskDetail bool
taskDetailScroll int
taskFilter string // `/` substring narrow over id/title/goal
taskFilterTyping bool
// command palette
paletteFilter string
paletteIndex int
// @ file picker (OverlayFiles) — workspace paths from the file.list reply
files []string // all workspace-relative paths for filesFor
filesFor string // sessionId the file list belongs to
filesLoading bool
fileFilter string // the query typed after @ (narrows the list)
fileIndex int
// status-bar segment visibility (OverlayStatusbar) — persisted TUI-local in tui-prefs.json.
// A segment id present in sbHidden is hidden; absent = shown. sbIndex is the toggle cursor.
sbHidden map[string]bool
sbIndex int
// actionsHidden mutes the inline action rows (tool calls / writes / approvals / grants) in
// the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json.
actionsHidden bool
// thinkingShown reveals the model's reasoning/thinking blocks in the OUTPUT transcript.
// Off by default (collapsed to a one-line summary) so the trace doesn't drown the answer;
// toggled from the palette ("thinking"). Persisted in tui-prefs.json.
thinkingShown bool
// outputScroll is how many rows up from the bottom the OUTPUT transcript is scrolled
// (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back.
outputScroll int
// event-inspector filter (OverlayEventInspector): narrows the event list by a substring
// of type/detail. eventFilterTyping is true while the operator is editing the query after /.
eventFilter string
eventFilterTyping bool
// standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply.
grants []protocol.GrantDto
grantsLoading bool
grantIndex int
// grant scope picker (OverlayGrantScope) — chosen when the operator presses A on an approval.
// grantScopeIndex selects session/project/global; grantFor holds the approval being widened.
grantScopeIndex int
grantFor *Approval
// animation
frame int // tick counter; drives spinner + caret blink
ticking bool // true while a tick loop is scheduled. The loop is gated on animating()
// so an idle screen stops redrawing — which lets native terminal selection survive
// (the 120ms redraw used to wipe a mouse drag every frame).
// snapshot phase
snapshotPhase bool
pendingEvents []protocol.ServerMessage
// lastBase is the full-screen render behind the active modal, stashed by View each
// frame so center() can composite a modal over a dimmed copy of it (transparent
// backdrop) instead of an opaque scrim. Transient render scratch — not real state.
lastBase string
// approval steering input buffer
steerBuffer string
steering bool
// approvalArmed is set while a high-tier (T3+) approve is awaiting its second
// confirming keypress — the destructive-action safety. Any non-confirm key
// disarms it; a confirming `y`/`a`/enter sends the decision.
approvalArmed bool
// clarification view (the interactive question form)
clarFocus int // focused question index
clarCursor int // option cursor in the focused question (== len(opts) → custom slot)
clarChosen []map[int]bool // per-question selected option indices
clarText []string // per-question free-text / custom answer
clarTyping bool // typing into the custom buffer for the focused question
clarDismissed bool // peeked away from the form (it can be reopened)
// workflow-propose view (the router's candidate-workflow picker)
proposeCursor int // cursor over candidates (== len(candidates) → custom slot)
proposeText string // free-text answer typed into the custom slot
proposeTyping bool // typing into the custom buffer
proposeDismissed bool // peeked away from the picker (it can be reopened)
}
// NewModel builds the initial idle state.
func NewModel(client *ws.Client) Model {
return Model{
client: client,
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
projectProfileStaged: map[string]string{},
operatorProfileStaged: map[string]string{},
chatMode: ChatModeChat,
providerType: "LOCAL",
snapshotPhase: true,
eventStripShown: true,
transcriptSel: -1,
sbHidden: loadStatusbarHidden(),
actionsHidden: loadPrefs().InlineActionsHidden,
thinkingShown: loadPrefs().ThinkingShown,
}
}
// displayState derives the active screen. A session must be *entered*
// (sessionEntered) before its in-session or approval surfaces show — otherwise
// merely moving the list cursor onto a session with a pending gate would yank
// you into the approval, and `l` back-to-list couldn't escape it.
func (m Model) displayState() DisplayState {
if m.selectedID == "" || !m.sessionEntered {
return StateIdle
}
s := m.session(m.selectedID)
// A stage's open questions take precedence: the run is parked on them, and they
// must be answered (or peeked away) before anything else makes sense.
if s != nil && s.Clar != nil && !m.clarDismissed {
return StateClarification
}
// A router workflow proposal is the operator's call to make before chatting on.
if s != nil && s.Propose != nil && !m.proposeDismissed {
return StateWorkflowPropose
}
if m.approvalDismissed {
return StateInSession
}
if s != nil && s.Pending != nil {
return StateApproval
}
return StateInSession
}
func (m Model) session(id string) *Session {
for i := range m.sessions {
if m.sessions[i].ID == id {
return &m.sessions[i]
}
}
return nil
}
// syncPending re-points Pending at the selected queue entry, clamping the index
// so it stays in range as the queue grows and shrinks. Called after any queue
// mutation; Pending is nil exactly when the queue is empty.
func (s *Session) syncPending() {
if len(s.PendingQueue) == 0 {
s.PendingQueue = nil
s.PendingIdx = 0
s.Pending = nil
return
}
if s.PendingIdx < 0 {
s.PendingIdx = 0
}
if s.PendingIdx >= len(s.PendingQueue) {
s.PendingIdx = len(s.PendingQueue) - 1
}
s.Pending = s.PendingQueue[s.PendingIdx]
}
// enqueueApproval adds a gate to the queue (replacing one with the same request
// id, so a re-sent gate doesn't duplicate). The freshly-added gate is left where
// it is in the order; the current selection is preserved.
func (s *Session) enqueueApproval(a *Approval) {
for i, p := range s.PendingQueue {
if p.RequestID == a.RequestID {
s.PendingQueue[i] = a
s.syncPending()
return
}
}
s.PendingQueue = append(s.PendingQueue, a)
s.syncPending()
}
// removeApproval drops the gate with requestID and advances the selection. If the
// removed entry was before the cursor the index shifts down to stay on the same
// gate; if it was the selected one the cursor stays put (now showing the next gate).
func (s *Session) removeApproval(requestID string) {
idx := -1
for i, p := range s.PendingQueue {
if p.RequestID == requestID {
idx = i
break
}
}
if idx < 0 {
return
}
s.PendingQueue = append(s.PendingQueue[:idx], s.PendingQueue[idx+1:]...)
if idx < s.PendingIdx {
s.PendingIdx--
}
s.syncPending()
}
// clearApprovals empties the queue (used on resume/completion, where the server
// invalidates every gate at once).
func (s *Session) clearApprovals() {
s.PendingQueue = nil
s.PendingIdx = 0
s.Pending = nil
}
// navApproval moves the queue selection by dir (wrapping) and re-syncs Pending.
func (s *Session) navApproval(dir int) {
n := len(s.PendingQueue)
if n <= 1 {
return
}
s.PendingIdx = (s.PendingIdx + dir + n) % n
s.syncPending()
}
// ensureSession returns the session with id, creating an ACTIVE entry if absent.
// Session existence is derived from the event stream (any event for an unknown
// session vivifies it) rather than a dedicated control frame.
func (m *Model) ensureSession(id string) *Session {
if s := m.session(id); s != nil {
return s
}
m.sessions = append(m.sessions, Session{
ID: id, Status: "ACTIVE", LastEventAt: nowMillis(),
})
return &m.sessions[len(m.sessions)-1]
}
// filteredSessions applies the workflow-id filter.
func (m Model) filteredSessions() []Session {
if m.filter == "" {
return m.sessions
}
out := make([]Session, 0, len(m.sessions))
for _, s := range m.sessions {
if containsFold(s.WorkflowID, m.filter) {
out = append(out, s)
}
}
return out
}