54f94a549f
Palette: each command now shows its bare-key shortcut in a key column (and the filter matches on it), so the palette teaches the shortcuts instead of hiding them. Status bar: a new "status bar" palette command opens a toggle overlay to show/hide the non-essential segments (current stage, session status, workspace path, model, last-event clock, resource gauge); correx/connection/session-name/spinner stay. Choices persist TUI-local in tui-prefs.json (JSON in the config dir — display state, kept out of the shared/replayed server config) and reload on launch. Also trims the in-session footer (drops stats/model — both reachable via `p cmds`) so it no longer overflows + truncates `q quit` at ~100 cols after the transcript-nav additions. Tests cover toggle→render gating and prefs persistence; verified via cmd/preview renders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
515 lines
15 KiB
Go
515 lines
15 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
|
|
OverlaySessions
|
|
OverlayFiles
|
|
OverlayStatusbar
|
|
)
|
|
|
|
// RouterEntry is one line in a session's conversation transcript.
|
|
type RouterEntry struct {
|
|
Role string // user | router | tool | narration | narration_llm
|
|
Content string
|
|
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
|
|
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
|
|
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
|
|
|
|
// 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(),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|