55a18b4b3a
Go TUI decodes model.changed / model.list / resource.status and renders a status-bar VRAM/GPU%/RAM gauge plus a models overlay (m key / palette 'models'): lists configured models with the resident one marked, enter swaps (SwapModel), c clears the pin (ClearModelPin). All three new server messages are non-event-bearing control/gauge frames, applied immediately like provider.status_changed. Server addition required for the picker: ServerMessage.ModelList (model.list, NonEventMessage) sent once in the initial snapshot from ManagedInferenceRouter.availableModelIds()/currentModelId() — the TUI otherwise cannot enumerate swap targets. Completes the 5-slice model-lifecycle feature. ./gradlew check green; go build/vet clean. Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 5 of 5).
252 lines
5.1 KiB
Go
252 lines
5.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
|
|
)
|
|
|
|
// 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
|
|
)
|
|
|
|
// RouterEntry is one line in a session's conversation transcript.
|
|
type RouterEntry struct {
|
|
Role string // user | router | tool
|
|
Content string
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
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
|
|
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
|
|
|
|
// 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
|
|
|
|
// diff / overlay
|
|
overlay OverlayKind
|
|
overlayEventIdx int
|
|
diffScrollOffset int
|
|
eventStripShown 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{},
|
|
chatMode: ChatModeChat,
|
|
providerType: "LOCAL",
|
|
snapshotPhase: true,
|
|
eventStripShown: true,
|
|
}
|
|
}
|
|
|
|
// displayState derives the active screen, matching the Kotlin extension.
|
|
func (m Model) displayState() DisplayState {
|
|
if m.selectedID == "" {
|
|
return StateIdle
|
|
}
|
|
hasApproval := false
|
|
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
|
hasApproval = true
|
|
}
|
|
switch {
|
|
case m.approvalDismissed:
|
|
return StateInSession
|
|
case hasApproval:
|
|
return StateApproval
|
|
case m.sessionEntered:
|
|
return StateInSession
|
|
default:
|
|
return StateIdle
|
|
}
|
|
}
|
|
|
|
func (m Model) session(id string) *Session {
|
|
for i := range m.sessions {
|
|
if m.sessions[i].ID == id {
|
|
return &m.sessions[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|