3600ec6897
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.
269 lines
5.8 KiB
Go
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
|
|
}
|