5ed8ebd0c5
The reasoning trace was rendered twice: once as its own "thinking" row on inference.completed, and again as a ✼ block on the following tool row, whose Reasoning was copied from Model.lastReasoning. The fallback branch was worse — lastReasoning is current model state, so every tool row with no Reasoning of its own (all non-diff rows, all snapshot-restored rows) got the newest trace stamped on it retroactively. Drop the block, the copy, and the now-dead RouterEntry.Reasoning / Model.lastReasoning; the standalone row already covers tool turns. actionToolText clipped every summary to 48 columns, which cut tool output and — worse — harness coach text: read-before-write rejections, write blocks, gate feedback, exactly the messages that say whether the agent was steered or silently blocked. The action renderer already wraps to panel width, so the clip was the only single-line ceiling; raise it to 4000 chars. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
654 lines
20 KiB
Go
654 lines
20 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
|
|
OverlayHealth
|
|
OverlaySessions
|
|
OverlayTasks
|
|
OverlayFiles
|
|
OverlayStatusbar
|
|
OverlayGrants
|
|
OverlayGrantScope
|
|
OverlayHelp
|
|
OverlayProjectProfile
|
|
OverlayOperatorProfile
|
|
OverlayPlan
|
|
)
|
|
|
|
// RouterEntry is one line in a session's conversation transcript.
|
|
type RouterEntry struct {
|
|
Role string // user | router | tool | narration | narration_llm | action
|
|
Content string
|
|
Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟)
|
|
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
|
|
}
|
|
|
|
// PlanStageStatus tracks a stage's lifecycle in a locked execution plan.
|
|
type PlanStageStatus int
|
|
|
|
const (
|
|
PlanPending PlanStageStatus = iota
|
|
PlanRunning
|
|
PlanCompleted
|
|
PlanFailed
|
|
)
|
|
|
|
// PlanStage is one stage in a locked execution plan, with live execution status.
|
|
type PlanStage struct {
|
|
ID string
|
|
Status PlanStageStatus
|
|
}
|
|
|
|
// ToolStatus mirrors the Kotlin ToolDisplayStatus.
|
|
type ToolStatus int
|
|
|
|
const (
|
|
ToolStarted ToolStatus = iota
|
|
ToolCompleted
|
|
ToolFailed
|
|
ToolRejected
|
|
)
|
|
|
|
type ToolRecord struct {
|
|
Name string
|
|
Tier int
|
|
Status ToolStatus
|
|
Params []string // pretty "key=value" call args, from tool.started
|
|
}
|
|
|
|
// ManifestTool is a declared (not-yet-run) tool from a stage manifest.
|
|
type ManifestTool struct {
|
|
Name string
|
|
Tier int
|
|
}
|
|
|
|
// ProfileField is one editable row in the project/operator profile editors — a flat
|
|
// key/value text field, mirroring the config editor's STRING-field editing model.
|
|
type ProfileField struct {
|
|
Key string
|
|
Value string
|
|
}
|
|
|
|
// 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
|
|
named bool // Name came from a session.renamed (intent-derived title); don't clobber with workflowId
|
|
LastEventAt int64
|
|
CurrentStage string
|
|
PlanGoal string
|
|
PlanStages []PlanStage
|
|
WorkspaceRoot string // bound cwd, from session.workspace_bound
|
|
LastOutput string
|
|
LastResponse string
|
|
Tools []ToolRecord
|
|
ToolsByStage map[string][]ManifestTool
|
|
TokenBudgetByStage map[string]int // stage -> ceiling, from stage.tool_manifest
|
|
StageTokensUsed int // tokens used by the most recent inference in CurrentStage
|
|
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)
|
|
|
|
// LastReview is the most recent semantic reviewer (Gate 3) verdict for this
|
|
// session, from review.findings. Nil until the first review lands.
|
|
LastReview *ReviewResult
|
|
}
|
|
|
|
// ReviewResult is the semantic reviewer's verdict + findings over a stage's
|
|
// produced files (mirrors ReviewFindingsRaisedEvent).
|
|
type ReviewResult struct {
|
|
StageID string
|
|
Verdict string // PASS | WARN | FAIL
|
|
Findings []ReviewFinding
|
|
Blocked bool
|
|
}
|
|
|
|
type ReviewFinding struct {
|
|
Severity string
|
|
Confidence float64
|
|
Category string
|
|
Target string
|
|
Message string
|
|
SuggestedFix string
|
|
Correctness bool
|
|
}
|
|
|
|
// 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
|
|
// launcher (idle screen): the active "what to launch" selection — 0 = chat, 1..N =
|
|
// workflows[i-1] — cycled with Tab and shown at the input's lower-right. railHidden
|
|
// folds away the idle status/quick-keys rail.
|
|
launcherWf int
|
|
railHidden bool
|
|
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
|
|
modalScroll int // body scroll for tall fixed-content modals (help, stats)
|
|
eventStripShown bool
|
|
// in-session right panel: 0 = events, 1 = changes (token usage + changed files),
|
|
// 2 = off (output full-width). Cycled with `d`.
|
|
rightPanel int
|
|
|
|
// 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
|
|
|
|
// project profile editor (OverlayProjectProfile) — populated by project_profile.snapshot;
|
|
// mirrors the config editor's field-list/staged-edit pattern. fields[0] is "about",
|
|
// fields[1] is "conventions" (semicolon-joined), the rest are one row per command.
|
|
projectProfileFields []ProfileField
|
|
projectProfileIndex int
|
|
projectProfileStaged map[string]string
|
|
projectProfileEditing bool
|
|
projectProfileEditBuf string
|
|
projectProfileError string
|
|
projectProfileLoading bool
|
|
|
|
// operator profile editor (OverlayOperatorProfile) — populated by operator_profile.snapshot.
|
|
// fields are "about", "approvalMode", "preferredModels" (comma-joined), "conventions"
|
|
// (semicolon-joined). proposedAdaptation is read-only (from ProfileAdaptationService).
|
|
operatorProfileFields []ProfileField
|
|
operatorProfileIndex int
|
|
operatorProfileStaged map[string]string
|
|
operatorProfileEditing bool
|
|
operatorProfileEditBuf string
|
|
operatorProfileError string
|
|
operatorProfileLoading bool
|
|
operatorProfileProposed string
|
|
|
|
// 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
|
|
|
|
// health checks (OverlayHealth) — system-scoped, populated by the health.checks reply
|
|
health *protocol.HealthDto
|
|
healthLoading 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
|
|
|
|
// task board (OverlayTasks) — all tasks across projects, fetched over HTTP from
|
|
// GET /tasks. taskDetail toggles the per-task detail pane (built from the list payload).
|
|
taskList []TaskSummary
|
|
taskListIndex int
|
|
taskListLoading bool
|
|
taskListErr string
|
|
taskDetail bool
|
|
taskDetailScroll int
|
|
taskFilter string // `/` substring narrow over id/title/goal
|
|
taskFilterTyping bool
|
|
|
|
// 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
|
|
|
|
// actionsHidden mutes the inline action rows (tool calls / writes / approvals / grants) in
|
|
// the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json.
|
|
actionsHidden bool
|
|
|
|
// thinkingShown reveals the model's reasoning/thinking blocks in the OUTPUT transcript.
|
|
// Off by default (collapsed to a one-line summary) so the trace doesn't drown the answer;
|
|
// toggled from the palette ("thinking"). Persisted in tui-prefs.json.
|
|
thinkingShown bool
|
|
|
|
// outputScroll is how many rows up from the bottom the OUTPUT transcript is scrolled
|
|
// (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back.
|
|
outputScroll int
|
|
|
|
// event-inspector filter (OverlayEventInspector): narrows the event list by a substring
|
|
// of type/detail. eventFilterTyping is true while the operator is editing the query after /.
|
|
eventFilter string
|
|
eventFilterTyping bool
|
|
|
|
// standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply.
|
|
grants []protocol.GrantDto
|
|
grantsLoading bool
|
|
grantIndex int
|
|
// grant scope picker (OverlayGrantScope) — chosen when the operator presses A on an approval.
|
|
// grantScopeIndex selects session/project/global; grantFor holds the approval being widened.
|
|
grantScopeIndex int
|
|
grantFor *Approval
|
|
|
|
// 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{},
|
|
projectProfileStaged: map[string]string{},
|
|
operatorProfileStaged: map[string]string{},
|
|
chatMode: ChatModeChat,
|
|
providerType: "LOCAL",
|
|
snapshotPhase: true,
|
|
eventStripShown: true,
|
|
transcriptSel: -1,
|
|
sbHidden: loadStatusbarHidden(),
|
|
actionsHidden: loadPrefs().InlineActionsHidden,
|
|
thinkingShown: loadPrefs().ThinkingShown,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|