4e4d7c186a
The collapsed tool row showed "tool output (N chars)" where N was len() of the
raw unified diff — i.e. the diff blob's *byte* length (headers, @@, +/-/context
all counted), labelled "chars". That number corresponded to nothing the operator
cares about, and len() is bytes not characters.
- Summarise a write/edit by the diff's row count instead — "· diff (7 rows) —
^x to view" — matching exactly what the ^x modal reports. Non-diff output (the
defensive fallback) now counts runes, not bytes, so "N chars" is truthful.
Retire itoaLen (the byte-count-as-"len" footgun).
- Harden the (+a −b) line counts in diffSummary: guard the file header with the
trailing space ("+++ "/"--- "), so a changed line whose content starts with
"++"/"--" is counted instead of mistaken for a header. Apply the same guard in
parseUnifiedDiff so such a line is rendered (and counted) rather than dropped.
tool_summary_test.go covers the diff-row summary, the rune (not byte) count, and
the ++/-- content-line edge case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
659 lines
20 KiB
Go
659 lines
20 KiB
Go
package app
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/correx/tui-go/internal/protocol"
|
||
)
|
||
|
||
func nowMillis() int64 { return time.Now().UnixMilli() }
|
||
|
||
func newSessionID() string {
|
||
b := make([]byte, 16)
|
||
_, _ = rand.Read(b)
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
func containsFold(haystack, needle string) bool {
|
||
return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
|
||
}
|
||
|
||
func formatTime(epochMillis int64) string {
|
||
return time.UnixMilli(epochMillis).UTC().Format("15:04:05")
|
||
}
|
||
|
||
// inferCategory maps an event type string to a display category, matching the
|
||
// Kotlin inferCategory heuristic.
|
||
func inferCategory(t string) string {
|
||
lt := strings.ToLower(t)
|
||
switch {
|
||
case strings.Contains(lt, "approval"):
|
||
return "Approval"
|
||
case strings.Contains(lt, "infer"):
|
||
return "Inference"
|
||
case strings.Contains(lt, "tool"):
|
||
return "Tool"
|
||
case strings.Contains(lt, "context"):
|
||
return "Context"
|
||
case strings.Contains(lt, "stage"), strings.Contains(lt, "lifecycle"):
|
||
return "Lifecycle"
|
||
case strings.Contains(lt, "session"), strings.Contains(lt, "artifact"):
|
||
return "Domain"
|
||
default:
|
||
return "Lifecycle"
|
||
}
|
||
}
|
||
|
||
// narrationLine maps a progress frame to a dim router-feed line, or "" if the
|
||
// frame is not a narration trigger.
|
||
func narrationLine(msg protocol.ServerMessage) string {
|
||
switch msg.Type {
|
||
case protocol.TypeStageStarted:
|
||
return "→ " + msg.StageID
|
||
case protocol.TypeStageCompleted:
|
||
return "✓ " + msg.StageID
|
||
case protocol.TypeStageFailed:
|
||
if msg.Reason != "" {
|
||
return "⚠ " + msg.StageID + ": " + msg.Reason
|
||
}
|
||
return "⚠ " + msg.StageID
|
||
case protocol.TypeSessionPaused:
|
||
return "⏸ paused"
|
||
case protocol.TypeSessionResumed:
|
||
return "▶ resumed"
|
||
case protocol.TypeSessionCompleted:
|
||
return "✓ workflow complete"
|
||
case protocol.TypeSessionFailed:
|
||
if msg.Reason != "" {
|
||
return "✗ workflow failed: " + msg.Reason
|
||
}
|
||
return "✗ workflow failed"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// applyServer mutates the model for a single (non-buffered) server message.
|
||
func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||
debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason)
|
||
// Auto-vivify: any event for an unknown session creates it, so session
|
||
// existence is derived from the event stream, not a dedicated control frame.
|
||
if msg.IsEventBearing() && msg.SessionID != "" {
|
||
m.ensureSession(msg.SessionID)
|
||
}
|
||
// Inject dim narration lines for progress frames. Runs in addition to the
|
||
// per-type handling below (which feeds the events panel separately).
|
||
if line := narrationLine(msg); line != "" {
|
||
m.appendRouter(msg.SessionID, RouterEntry{Role: "narration", Content: line})
|
||
}
|
||
switch msg.Type {
|
||
case protocol.TypeSessionAnnounced:
|
||
m.onSessionAnnounced(msg)
|
||
case protocol.TypeRouterNarration:
|
||
entry := RouterEntry{Role: "narration_llm", Content: msg.Content}
|
||
if msg.LatencyMs != nil && msg.TotalTokens != nil {
|
||
entry.Metrics = &TurnMetrics{LatencyMs: *msg.LatencyMs, TotalTokens: *msg.TotalTokens}
|
||
}
|
||
m.appendRouter(msg.SessionID, entry)
|
||
case protocol.TypeChatTurn:
|
||
m.routerConnected = true
|
||
role := "router"
|
||
if msg.Role == "USER" {
|
||
role = "user"
|
||
}
|
||
entry := RouterEntry{Role: role, Content: msg.Content}
|
||
if msg.LatencyMs != nil && msg.TotalTokens != nil {
|
||
entry.Metrics = &TurnMetrics{LatencyMs: *msg.LatencyMs, TotalTokens: *msg.TotalTokens}
|
||
}
|
||
m.appendRouter(msg.SessionID, entry)
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.LastEventAt = nowMillis()
|
||
}
|
||
case protocol.TypeSessionPaused:
|
||
label := "PAUSED"
|
||
if msg.Reason == "APPROVAL_PENDING" {
|
||
label = "PAUSED awaiting approval"
|
||
}
|
||
m.touch(msg.SessionID, label)
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = false
|
||
}
|
||
case protocol.TypeSessionResumed:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Status = "ACTIVE"
|
||
s.clearApprovals()
|
||
s.LastEventAt = nowMillis()
|
||
}
|
||
case protocol.TypeSessionCompleted:
|
||
m.touch(msg.SessionID, "COMPLETED")
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = false
|
||
s.Clar = nil
|
||
s.Propose = nil
|
||
s.clearApprovals()
|
||
}
|
||
case protocol.TypeSessionFailed:
|
||
m.touch(msg.SessionID, "FAILED")
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = false
|
||
s.Clar = nil
|
||
s.Propose = nil
|
||
s.clearApprovals()
|
||
}
|
||
case protocol.TypeStageStarted:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.CurrentStage = msg.StageID
|
||
s.Tools = nil
|
||
s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
|
||
}
|
||
case protocol.TypeStageCompleted:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.CurrentStage = ""
|
||
s.addEvent(msg.OccurredAt, "StageCompleted", msg.StageID)
|
||
}
|
||
case protocol.TypeStageFailed:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.CurrentStage = ""
|
||
s.addEvent(msg.OccurredAt, "StageFailed", msg.StageID)
|
||
}
|
||
case protocol.TypeInferenceStarted:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = true
|
||
s.addEvent(nowMillis(), "InferenceStarted", msg.StageID)
|
||
}
|
||
case protocol.TypeInferenceDone:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = false
|
||
s.LastOutput = msg.Summary
|
||
if msg.Response != "" {
|
||
s.LastResponse = msg.Response
|
||
}
|
||
s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
|
||
}
|
||
case protocol.TypeInferenceTimeout:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = false
|
||
s.addEvent(nowMillis(), "InferenceTimedOut", msg.StageID)
|
||
}
|
||
case protocol.TypeInferenceFailed:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = false
|
||
s.addEvent(nowMillis(), "InferenceFailed", msg.StageID+": "+msg.Reason)
|
||
}
|
||
case protocol.TypeInferenceRetry:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.addEvent(nowMillis(), "RetryAttempted",
|
||
fmt.Sprintf("%s (%d/%d): %s", msg.StageID, msg.AttemptNumber, msg.MaxAttempts, msg.FailureReason))
|
||
}
|
||
case protocol.TypeToolStarted:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = true
|
||
s.Tools = append(s.Tools, ToolRecord{Name: msg.ToolName, Status: ToolStarted})
|
||
if len(s.Tools) > 8 {
|
||
s.Tools = s.Tools[len(s.Tools)-8:]
|
||
}
|
||
s.LastEventAt = nowMillis()
|
||
}
|
||
// Tool-start is not surfaced inline (it doubles up with the ✓/✎ result row); it
|
||
// stays in the tool list + EVENTS panel.
|
||
case protocol.TypeToolCompleted:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Active = false
|
||
s.markTool(msg.ToolName, ToolCompleted)
|
||
s.LastOutput = msg.ToolName + ": " + msg.Summary
|
||
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
|
||
}
|
||
if msg.Diff != nil && *msg.Diff != "" {
|
||
// A diff = a write/edit: name the file + the line delta, then keep the
|
||
// existing collapsed diff row (^x opens the full diff).
|
||
path, add, del := diffSummary(*msg.Diff)
|
||
m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del))
|
||
m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff})
|
||
} else {
|
||
m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary))
|
||
}
|
||
case protocol.TypeToolAssessed:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
if msg.Disposition != "PROCEED" || len(msg.AssessedIssues) > 0 {
|
||
detail := msg.Disposition
|
||
if len(msg.AssessedIssues) > 0 {
|
||
detail += " [" + msg.AssessedIssues[0].Code + "]"
|
||
}
|
||
s.LastOutput = msg.ToolName + " assessed: " + detail
|
||
s.addEvent(msg.OccurredAt, "ToolAssessed", detail)
|
||
}
|
||
}
|
||
case protocol.TypeToolFailed:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.markTool(msg.ToolName, ToolFailed)
|
||
s.LastEventAt = msg.OccurredAt
|
||
}
|
||
m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason))
|
||
case protocol.TypeToolRejected:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.markTool(msg.ToolName, ToolRejected)
|
||
s.LastEventAt = nowMillis()
|
||
}
|
||
m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason))
|
||
case protocol.TypeArtifactCreated:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
|
||
}
|
||
case protocol.TypeArtifactValid:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.addEvent(nowMillis(), "ArtifactValidated", msg.ArtifactID)
|
||
}
|
||
case protocol.TypePlanLocked:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.addEvent(nowMillis(), "PlanLocked", strings.Join(msg.StageIDs, " → "))
|
||
s.LastOutput = "plan locked: " + strings.Join(msg.StageIDs, " → ")
|
||
}
|
||
case protocol.TypeWorkspaceBound:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.WorkspaceRoot = msg.WorkspaceRoot
|
||
}
|
||
case protocol.TypeApprovalRequired:
|
||
m.onApprovalRequired(msg)
|
||
case protocol.TypeClarification:
|
||
m.onClarificationRequired(msg)
|
||
case protocol.TypeWorkflowProposed:
|
||
m.onWorkflowProposed(msg)
|
||
case protocol.TypeApprovalResolved:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
// The resolved gate names no tool on the wire; recover it from the pending
|
||
// queue before dropping the gate, for the inline row.
|
||
tool := ""
|
||
for _, a := range s.PendingQueue {
|
||
if a.RequestID == msg.RequestID {
|
||
tool = a.ToolName
|
||
break
|
||
}
|
||
}
|
||
// Drop just the resolved gate; any others stay queued and the band
|
||
// advances to the next rather than vanishing entirely.
|
||
s.removeApproval(msg.RequestID)
|
||
detail := msg.Outcome
|
||
if msg.Reason != "" {
|
||
detail += " — " + msg.Reason
|
||
}
|
||
s.addEvent(msg.OccurredAt, "ApprovalResolved", detail)
|
||
s.LastEventAt = nowMillis()
|
||
m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason))
|
||
}
|
||
case protocol.TypeSessionSnapshot:
|
||
m.onSnapshot(msg)
|
||
case protocol.TypeStageManifest:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
byStage := map[string][]ManifestTool{}
|
||
for _, st := range msg.Stages {
|
||
tools := make([]ManifestTool, 0, len(st.Tools))
|
||
for _, td := range st.Tools {
|
||
tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier})
|
||
}
|
||
byStage[st.StageID] = tools
|
||
}
|
||
s.ToolsByStage = byStage
|
||
}
|
||
case protocol.TypeProviderStatus:
|
||
m.currentModel = msg.ProviderID
|
||
if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") {
|
||
m.providerType = "LOCAL"
|
||
} else {
|
||
m.providerType = "REMOTE"
|
||
}
|
||
case protocol.TypeWorkflowList:
|
||
m.workflows = m.workflows[:0]
|
||
for _, w := range msg.Workflows {
|
||
m.workflows = append(m.workflows, Workflow{ID: w.WorkflowID, Description: w.Description})
|
||
}
|
||
case protocol.TypeModelChanged:
|
||
if msg.Loaded {
|
||
m.currentModel = msg.ModelID
|
||
m.providerType = "LOCAL"
|
||
}
|
||
case protocol.TypeModelList:
|
||
m.availableModels = append(m.availableModels[:0], msg.Models...)
|
||
if msg.Current != "" {
|
||
m.currentModel = msg.Current
|
||
m.providerType = "LOCAL"
|
||
}
|
||
case protocol.TypeResourceStatus:
|
||
m.gpuUsedMB = msg.GpuMemoryUsedMb
|
||
m.gpuTotalMB = msg.GpuMemoryTotalMb
|
||
m.gpuUtil = msg.GpuUtilizationPct
|
||
m.ramMB = msg.ProcessRssMb
|
||
m.sysRamUsedMB = msg.SystemRamUsedMb
|
||
m.sysRamTotalMB = msg.SystemRamTotalMb
|
||
case protocol.TypeArtifactList:
|
||
m.artifacts = msg.Artifacts
|
||
m.artifactsFor = msg.SessionID
|
||
m.artifactsLoading = false
|
||
m.artifactScroll = 0
|
||
if m.artifactsIndex >= len(m.artifacts) {
|
||
m.artifactsIndex = 0
|
||
}
|
||
case protocol.TypeSessionStats:
|
||
m.stats = msg.Stats
|
||
m.statsFor = msg.SessionID
|
||
m.statsLoading = false
|
||
case protocol.TypeIdeaList:
|
||
m.ideas = msg.Ideas
|
||
m.ideasLoading = false
|
||
if m.ideasIndex >= len(m.ideas) {
|
||
m.ideasIndex = 0
|
||
}
|
||
case protocol.TypeFileList:
|
||
m.files = msg.Paths
|
||
m.filesFor = msg.SessionID
|
||
m.filesLoading = false
|
||
if m.fileIndex >= len(m.filteredFiles()) {
|
||
m.fileIndex = 0
|
||
}
|
||
case protocol.TypeGrantList:
|
||
m.grants = msg.Grants
|
||
m.grantsLoading = false
|
||
if m.grantIndex >= len(m.grants) {
|
||
m.grantIndex = 0
|
||
}
|
||
case protocol.TypeConfigSnapshot:
|
||
m.configFields = msg.ConfigFields
|
||
m.configRestart = msg.ConfigRestartRequired
|
||
m.configLoading = false
|
||
if msg.ConfigError != nil {
|
||
m.configError = *msg.ConfigError
|
||
} else {
|
||
// A clean snapshot means the staged edits were accepted (or this is a fresh fetch).
|
||
m.configError = ""
|
||
m.configStaged = map[string]string{}
|
||
}
|
||
if m.configIndex >= len(m.configFields) {
|
||
m.configIndex = 0
|
||
}
|
||
case protocol.TypeRouterResponse, protocol.TypeProtocolError:
|
||
// Recognized but intentionally not rendered here: router.response is
|
||
// superseded by chat.turn events on the global stream, and protocol_error is
|
||
// a transport diagnostic. Explicit no-op so the default below means *unknown*.
|
||
default:
|
||
// Unknown-event raw fallback (TUI-requirements §2): a session-scoped event
|
||
// type this client doesn't recognize is surfaced as a raw row in the event
|
||
// stream, never silently dropped — the frontend must not lie by omission.
|
||
if msg.SessionID != "" {
|
||
s := m.ensureSession(msg.SessionID)
|
||
at := msg.OccurredAt
|
||
if at == 0 {
|
||
at = nowMillis()
|
||
}
|
||
s.addEvent(at, msg.Type, "(raw — unrendered event)")
|
||
}
|
||
}
|
||
|
||
// Background-update badge for non-selected sessions.
|
||
if sid := sessionIDOf(msg); sid != "" && sid != m.selectedID {
|
||
m.bgUpdates++
|
||
}
|
||
}
|
||
|
||
func sessionIDOf(msg protocol.ServerMessage) string {
|
||
switch msg.Type {
|
||
case protocol.TypeStageManifest, protocol.TypeSnapshotComplete,
|
||
protocol.TypeProtocolError, protocol.TypeProviderStatus,
|
||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
||
protocol.TypeIdeaList, protocol.TypeFileList, protocol.TypeGrantList:
|
||
return ""
|
||
default:
|
||
return msg.SessionID
|
||
}
|
||
}
|
||
|
||
// --- inline action-row helpers (external-feedback events surfaced in OUTPUT) ---
|
||
|
||
// diffSummary extracts the written path and +/- line counts from a unified diff for the
|
||
// inline write row. Falls back to "file" when no `+++` header is present.
|
||
func diffSummary(diff string) (path string, added, removed int) {
|
||
path = "file"
|
||
for _, ln := range strings.Split(diff, "\n") {
|
||
switch {
|
||
case strings.HasPrefix(ln, "+++ "):
|
||
p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ "))
|
||
p = strings.TrimPrefix(p, "b/")
|
||
if p != "" && p != "/dev/null" {
|
||
path = p
|
||
}
|
||
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++ "):
|
||
added++
|
||
case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "--- "):
|
||
removed++
|
||
}
|
||
}
|
||
return path, added, removed
|
||
}
|
||
|
||
// countSuffix renders the " (+a −b)" delta, or "" when nothing changed.
|
||
func countSuffix(added, removed int) string {
|
||
if added == 0 && removed == 0 {
|
||
return ""
|
||
}
|
||
return " (+" + itoa(added) + " −" + itoa(removed) + ")"
|
||
}
|
||
|
||
// actionToolText joins a tool label with a short, clipped result summary.
|
||
func actionToolText(label, summary string) string {
|
||
s := strings.TrimSpace(summary)
|
||
if s == "" {
|
||
return label
|
||
}
|
||
return label + " · " + clip(s, 48)
|
||
}
|
||
|
||
func approvalIcon(outcome string) string {
|
||
if outcome == "REJECTED" {
|
||
return "✕"
|
||
}
|
||
return "⌘"
|
||
}
|
||
|
||
// approvalActionText renders the inline approval row, noting an auto-approval that fired via a
|
||
// standing grant (reason "grant:<id>").
|
||
func approvalActionText(outcome, tool, reason string) string {
|
||
verb := "approved"
|
||
switch outcome {
|
||
case "REJECTED":
|
||
verb = "rejected"
|
||
case "AUTO_APPROVED":
|
||
verb = "auto-approved"
|
||
}
|
||
txt := verb
|
||
if tool != "" {
|
||
txt = verb + " " + tool
|
||
}
|
||
if strings.HasPrefix(reason, "grant:") {
|
||
txt += " · via grant"
|
||
}
|
||
return txt
|
||
}
|
||
|
||
// onSessionAnnounced fills in a session's workflow identity (the announce is the
|
||
// only event carrying workflowId) and applies auto-focus. The session entry itself
|
||
// was already created by the auto-vivify path in applyServer.
|
||
func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) {
|
||
s := m.ensureSession(msg.SessionID)
|
||
s.Status = "ACTIVE"
|
||
if msg.WorkflowID != "" {
|
||
s.WorkflowID = msg.WorkflowID
|
||
s.Name = msg.WorkflowID
|
||
}
|
||
s.LastEventAt = nowMillis()
|
||
if m.pendingWorkflowFocus {
|
||
m.selectedID = msg.SessionID
|
||
m.sessionEntered = true
|
||
m.pendingWorkflowFocus = false
|
||
} else if m.selectedID == "" {
|
||
m.selectedID = msg.SessionID
|
||
}
|
||
}
|
||
|
||
func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
||
risk := "unknown"
|
||
var rationale []string
|
||
if msg.RiskSummary != nil {
|
||
risk = msg.RiskSummary.Level
|
||
rationale = msg.RiskSummary.Rationale
|
||
}
|
||
info := &Approval{
|
||
RequestID: msg.RequestID,
|
||
SessionID: msg.SessionID,
|
||
Tier: msg.Tier,
|
||
Risk: risk,
|
||
ToolName: deref(&msg.ToolName),
|
||
Preview: derefp(msg.Preview),
|
||
Rationale: rationale,
|
||
}
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.enqueueApproval(info)
|
||
}
|
||
}
|
||
|
||
func (m *Model) onClarificationRequired(msg protocol.ServerMessage) {
|
||
qs := make([]ClarQuestion, 0, len(msg.Questions))
|
||
for _, q := range msg.Questions {
|
||
qs = append(qs, ClarQuestion{
|
||
ID: q.ID, Prompt: q.Prompt, Header: q.Header,
|
||
Options: q.Options, MultiSelect: q.MultiSelect,
|
||
})
|
||
}
|
||
c := &Clarification{
|
||
RequestID: msg.RequestID, SessionID: msg.SessionID,
|
||
StageID: msg.StageID, Questions: qs,
|
||
}
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Clar = c
|
||
}
|
||
if msg.SessionID == m.selectedID {
|
||
m.clarInitState(len(qs))
|
||
}
|
||
}
|
||
|
||
func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) {
|
||
cands := make([]ProposeCandidate, 0, len(msg.Candidates))
|
||
for _, c := range msg.Candidates {
|
||
cands = append(cands, ProposeCandidate{WorkflowID: c.WorkflowID, Reason: c.Reason})
|
||
}
|
||
p := &Proposal{
|
||
ProposalID: msg.ProposalID, SessionID: msg.SessionID,
|
||
Prompt: msg.Prompt, OriginalRequest: msg.OriginalRequest, Candidates: cands,
|
||
}
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.Propose = p
|
||
}
|
||
if msg.SessionID == m.selectedID {
|
||
m.proposeInitState()
|
||
}
|
||
}
|
||
|
||
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
|
||
var queue []*Approval
|
||
for _, a := range msg.PendingAppr {
|
||
queue = append(queue, &Approval{
|
||
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
|
||
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
|
||
})
|
||
}
|
||
status := "running"
|
||
if msg.State != nil {
|
||
status = msg.State.Status
|
||
}
|
||
if len(queue) > 0 {
|
||
status = "PAUSED awaiting approval"
|
||
}
|
||
sess := Session{
|
||
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
|
||
Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue,
|
||
}
|
||
sess.syncPending()
|
||
if msg.State != nil && msg.State.CurrentStageID != nil {
|
||
sess.CurrentStage = *msg.State.CurrentStageID
|
||
}
|
||
sess.LastOutput = msg.LastOutput
|
||
sess.LastResponse = msg.LastResponse
|
||
sess.WorkspaceRoot = msg.WorkspaceRoot
|
||
for _, e := range msg.RecentEvents {
|
||
sess.Events = append(sess.Events, EventEntry{formatTime(e.Timestamp), e.Type, e.Detail})
|
||
}
|
||
for _, t := range msg.Tools {
|
||
sess.Tools = append(sess.Tools, ToolRecord{Name: t.Name, Tier: t.Tier, Status: toolStatusOf(t.Status)})
|
||
}
|
||
// Replace existing session with same id, else append.
|
||
replaced := false
|
||
for i := range m.sessions {
|
||
if m.sessions[i].ID == msg.SessionID {
|
||
m.sessions[i] = sess
|
||
replaced = true
|
||
break
|
||
}
|
||
}
|
||
if !replaced {
|
||
m.sessions = append(m.sessions, sess)
|
||
}
|
||
if m.selectedID == "" {
|
||
m.selectedID = msg.SessionID
|
||
}
|
||
}
|
||
|
||
func (m *Model) touch(id, status string) {
|
||
if s := m.session(id); s != nil {
|
||
s.Status = status
|
||
s.LastEventAt = nowMillis()
|
||
}
|
||
}
|
||
|
||
// --- Session helpers ---
|
||
|
||
// maxSessionEvents bounds retained per-session history (effectively "all" for a
|
||
// session, while keeping memory bounded on very long runs). The EVENTS panel
|
||
// shows the tail that fits; the `e` inspector scrolls the full retained list.
|
||
const maxSessionEvents = 1000
|
||
|
||
func (s *Session) addEvent(epochMillis int64, typ, detail string) {
|
||
s.Events = append(s.Events, EventEntry{formatTime(epochMillis), typ, detail})
|
||
if len(s.Events) > maxSessionEvents {
|
||
s.Events = s.Events[len(s.Events)-maxSessionEvents:]
|
||
}
|
||
s.LastEventAt = epochMillis
|
||
}
|
||
|
||
func (s *Session) markTool(name string, status ToolStatus) {
|
||
for i := range s.Tools {
|
||
if s.Tools[i].Name == name && s.Tools[i].Status == ToolStarted {
|
||
s.Tools[i].Status = status
|
||
}
|
||
}
|
||
}
|
||
|
||
func toolStatusOf(s string) ToolStatus {
|
||
switch s {
|
||
case "COMPLETED":
|
||
return ToolCompleted
|
||
case "FAILED":
|
||
return ToolFailed
|
||
case "REJECTED":
|
||
return ToolRejected
|
||
default:
|
||
return ToolStarted
|
||
}
|
||
}
|
||
|
||
func deref(s *string) string {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
return *s
|
||
}
|
||
|
||
func derefp(s *string) string { return deref(s) }
|