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 OverlaySessions OverlayFiles OverlayStatusbar OverlayGrants OverlayGrantScope OverlayHelp ) // 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 } // ToolStatus mirrors the Kotlin ToolDisplayStatus. type ToolStatus int const ( ToolStarted ToolStatus = iota ToolCompleted ToolFailed ToolRejected ) type ToolRecord struct { Name string Tier int Status ToolStatus } // ManifestTool is a declared (not-yet-run) tool from a stage manifest. type ManifestTool struct { Name string Tier int } // 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 LastEventAt int64 CurrentStage string WorkspaceRoot string // bound cwd, from session.workspace_bound LastOutput string LastResponse string Tools []ToolRecord ToolsByStage map[string][]ManifestTool 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) } // 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 // 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 // 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 // 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 // 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 // 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{}, chatMode: ChatModeChat, providerType: "LOCAL", snapshotPhase: true, eventStripShown: true, transcriptSel: -1, sbHidden: loadStatusbarHidden(), actionsHidden: loadPrefs().InlineActionsHidden, } } // 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 }