Files
correx/apps/tui-go/internal/app/model.go
T
kami 3600ec6897 feat(tui): surface plane-2 rationale in the approval band
The plane-2 tool-call assessor records verified preconditions
("[PATH_OUTSIDE_WORKSPACE] …") and the server already ships them in
RiskSummaryDto.rationale, but the Go TUI's RiskSummaryDto had no
Rationale field — so the justification was silently dropped at decode
and the gate showed only an opaque tier.

Decode the rationale, carry it on Approval, and render it under the
header in the approval band (warn-marked, above the diff). This closes
the last deferred item of the plane-2 slice-1 plan: the assessment
surfaced to the approval UX. Golden test pins the rationale decode.
2026-06-03 00:30:26 +04:00

269 lines
5.8 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
// 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
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
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
// 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
}
// 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
}