Files
correx/apps/tui-go/internal/app/model.go
T
kami ee24b7786a fix(kernel,server,tui,inference): generic resource gauge + F-002/F-003/F-004
- generic SystemResourceProbe: owner-agnostic /proc/meminfo system RAM
  overlaid on the vendor GPU probe, wired on both managed and static paths
  so the VRAM/GPU/RAM gauge renders with an external llama-server (was dormant)
- F-002: classify provider 4xx (e.g. llama.cpp 400) as terminal, not retryable
  (except 408/429); stops retrying deterministic request failures to exhaustion
- F-003: FileSystemPromptLoader expands leading ~ / ~/ to user home
- F-004: map InferenceFailedEvent + RetryAttemptedEvent to new
  ServerMessage.InferenceFailed / RetryAttempted, decoded+rendered in tui-go;
  ArtifactContentStoredEvent explicitly mapped to null (internal, no warn)
- tests: tilde expansion, SystemResourceProbe (static/managed/fail-soft)
2026-06-10 11:07:08 +04:00

298 lines
7.1 KiB
Go

package app
import (
"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
)
// 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
)
// 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
}
// 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 *Approval
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
history map[string][]string
historyIndex int
savedBuffer string
// flow flags
sessionEntered bool
approvalDismissed bool
pendingWorkflowFocus bool
// router transcript
routerMessages map[string][]RouterEntry
routerConnected bool
chatMode string
// 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
// command palette
paletteFilter string
paletteIndex int
// animation
frame int // tick counter; drives spinner + caret blink
// snapshot phase
snapshotPhase bool
pendingEvents []protocol.ServerMessage
// approval steering input buffer
steerBuffer string
steering bool
}
// NewModel builds the initial idle state.
func NewModel(client *ws.Client) Model {
return Model{
client: client,
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
history: map[string][]string{},
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
chatMode: ChatModeChat,
providerType: "LOCAL",
snapshotPhase: true,
eventStripShown: true,
}
}
// 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
}
if m.approvalDismissed {
return StateInSession
}
if s := m.session(m.selectedID); 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
}
// 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
}