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>
931 lines
30 KiB
Go
931 lines
30 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))
|
||
}
|
||
|
||
// formatTime renders an event/activity instant as a local wall-clock time, matching the
|
||
// operator's clock (and the local date shown in the session list). It was previously forced
|
||
// to UTC, so every event timestamp read hours off the user's actual time.
|
||
func formatTime(epochMillis int64) string {
|
||
return time.UnixMilli(epochMillis).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.TypeSessionRenamed:
|
||
m.onSessionRenamed(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"
|
||
switch msg.Reason {
|
||
case "APPROVAL_PENDING":
|
||
label = "PAUSED awaiting approval"
|
||
case "CLARIFICATION_PENDING":
|
||
label = "PAUSED awaiting answer"
|
||
case "ABANDONED_STALE":
|
||
label = "PAUSED (stale)"
|
||
}
|
||
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.Clar = nil
|
||
s.LastEventAt = nowMillis()
|
||
}
|
||
if msg.SessionID == m.selectedID {
|
||
m.clarResetState()
|
||
}
|
||
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.StageTokensUsed = 0
|
||
s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
|
||
for i := range s.PlanStages {
|
||
if s.PlanStages[i].ID == msg.StageID {
|
||
s.PlanStages[i].Status = PlanRunning
|
||
break
|
||
}
|
||
}
|
||
}
|
||
case protocol.TypeStageCompleted:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.CurrentStage = ""
|
||
s.addEvent(msg.OccurredAt, "StageCompleted", msg.StageID)
|
||
for i := range s.PlanStages {
|
||
if s.PlanStages[i].ID == msg.StageID {
|
||
s.PlanStages[i].Status = PlanCompleted
|
||
break
|
||
}
|
||
}
|
||
}
|
||
case protocol.TypeStageFailed:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
s.CurrentStage = ""
|
||
s.addEvent(msg.OccurredAt, "StageFailed", msg.StageID)
|
||
for i := range s.PlanStages {
|
||
if s.PlanStages[i].ID == msg.StageID {
|
||
s.PlanStages[i].Status = PlanFailed
|
||
break
|
||
}
|
||
}
|
||
}
|
||
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
|
||
}
|
||
if msg.TotalTokens != nil {
|
||
s.StageTokensUsed = *msg.TotalTokens
|
||
}
|
||
s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
|
||
// Thread the model's reasoning trace into the transcript as a collapsed "thinking"
|
||
// row (revealed via the palette). Skipped when the model emits no separate channel.
|
||
if strings.TrimSpace(msg.Reasoning) != "" {
|
||
m.appendRouter(msg.SessionID, RouterEntry{Role: "thinking", Content: msg.Reasoning})
|
||
}
|
||
}
|
||
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, Params: msg.Params})
|
||
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 {
|
||
label := msg.ToolName
|
||
// Prefer the actual call args (path=…, command="…") over the affected-entities
|
||
// fallback: params show *what was asked*, not just what got touched.
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
if suffix := paramSuffix(lastToolParams(s, msg.ToolName)); suffix != "" {
|
||
label += suffix
|
||
} else if len(msg.AffectedEntities) > 0 {
|
||
label += " " + strings.Join(msg.AffectedEntities, ", ")
|
||
}
|
||
} else if len(msg.AffectedEntities) > 0 {
|
||
label += " " + strings.Join(msg.AffectedEntities, ", ")
|
||
}
|
||
m.appendAction(msg.SessionID, "✓", actionToolText(label, msg.Summary))
|
||
}
|
||
case protocol.TypeReviewFindings:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
findings := make([]ReviewFinding, 0, len(msg.ReviewFindings))
|
||
for _, f := range msg.ReviewFindings {
|
||
fix := ""
|
||
if f.SuggestedFix != nil {
|
||
fix = *f.SuggestedFix
|
||
}
|
||
findings = append(findings, ReviewFinding{
|
||
Severity: f.Severity,
|
||
Confidence: f.Confidence,
|
||
Category: f.Category,
|
||
Target: f.Target,
|
||
Message: f.Message,
|
||
SuggestedFix: fix,
|
||
Correctness: f.Correctness,
|
||
})
|
||
}
|
||
s.LastReview = &ReviewResult{
|
||
StageID: msg.StageID,
|
||
Verdict: msg.ReviewVerdict,
|
||
Findings: findings,
|
||
Blocked: msg.ReviewBlocked,
|
||
}
|
||
detail := msg.ReviewVerdict
|
||
if len(findings) > 0 {
|
||
detail += " (" + plural(len(findings), "finding") + ")"
|
||
}
|
||
if msg.ReviewBlocked {
|
||
detail += " · blocked"
|
||
}
|
||
s.LastOutput = "review: " + detail
|
||
s.addEvent(nowMillis(), "ReviewFindings", msg.StageID+": "+detail)
|
||
icon := "✓"
|
||
if msg.ReviewVerdict == "FAIL" {
|
||
icon = "✕"
|
||
} else if msg.ReviewVerdict == "WARN" {
|
||
icon = "▲"
|
||
}
|
||
m.appendAction(msg.SessionID, icon, "review "+detail)
|
||
}
|
||
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, " → ")
|
||
s.PlanGoal = msg.Content
|
||
s.PlanStages = make([]PlanStage, 0, len(msg.StageIDs))
|
||
for _, id := range msg.StageIDs {
|
||
s.PlanStages = append(s.PlanStages, PlanStage{ID: id, Status: PlanPending})
|
||
}
|
||
}
|
||
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, preview := "", ""
|
||
for _, a := range s.PendingQueue {
|
||
if a.RequestID == msg.RequestID {
|
||
tool = a.ToolName
|
||
preview = a.Preview
|
||
break
|
||
}
|
||
}
|
||
// Extract the target (file path for diffs, command for shell) from the
|
||
// preview so the action row reads "approved file_write → healthcheck.sh".
|
||
target := ""
|
||
if tool == "file_write" && isUnifiedDiff(preview) {
|
||
target = diffTarget(preview)
|
||
} else if tool == "shell" && strings.HasPrefix(preview, "{") {
|
||
target = previewTarget(preview)
|
||
}
|
||
// 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, target, msg.Reason))
|
||
}
|
||
case protocol.TypeSessionSnapshot:
|
||
m.onSnapshot(msg)
|
||
case protocol.TypeStageManifest:
|
||
if s := m.session(msg.SessionID); s != nil {
|
||
byStage := map[string][]ManifestTool{}
|
||
budgetByStage := map[string]int{}
|
||
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
|
||
if st.TokenBudget != nil {
|
||
budgetByStage[st.StageID] = *st.TokenBudget
|
||
}
|
||
}
|
||
s.ToolsByStage = byStage
|
||
s.TokenBudgetByStage = budgetByStage
|
||
}
|
||
case protocol.TypeProjectProfile:
|
||
errMsg := ""
|
||
if msg.ConfigError != nil {
|
||
errMsg = *msg.ConfigError
|
||
}
|
||
m.applyProjectProfileSnapshot(msg.About, msg.Conventions, msg.Commands, errMsg)
|
||
case protocol.TypeOperatorProfile:
|
||
errMsg := ""
|
||
if msg.ConfigError != nil {
|
||
errMsg = *msg.ConfigError
|
||
}
|
||
proposed := ""
|
||
if msg.ProposedAdaptation != nil {
|
||
proposed = *msg.ProposedAdaptation
|
||
}
|
||
m.applyOperatorProfileSnapshot(msg.About, msg.ApprovalMode, msg.PreferredModels, msg.Conventions, proposed, errMsg)
|
||
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.TypeHealthChecks:
|
||
m.health = msg.Health
|
||
m.healthLoading = false
|
||
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.TypeHealthChecks,
|
||
protocol.TypeFileList, protocol.TypeGrantList, protocol.TypeOperatorProfile:
|
||
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) + ")"
|
||
}
|
||
|
||
// lastToolParams returns the call args of the most recent invocation of the named tool. The ReAct
|
||
// loop runs one tool at a time per session, so the last started record is the one now completing.
|
||
func lastToolParams(s *Session, name string) []string {
|
||
for i := len(s.Tools) - 1; i >= 0; i-- {
|
||
if s.Tools[i].Name == name {
|
||
return s.Tools[i].Params
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// paramSuffix renders pretty call args as a " (k=v · k=v)" suffix, or "" when there are none.
|
||
// The clip is generous (200 cols) so real tool arguments like shell commands, grep patterns,
|
||
// and file paths are legible — the panel width is the real limit.
|
||
func paramSuffix(params []string) string {
|
||
if len(params) == 0 {
|
||
return ""
|
||
}
|
||
return " (" + clip(strings.Join(params, " · "), 200) + ")"
|
||
}
|
||
|
||
// actionToolText joins a tool label with its result summary. The action row wraps its
|
||
// content to the panel width, so the summary is kept whole rather than clipped to one
|
||
// line — tool output and harness coach text (read-before-write, write blocks, gate
|
||
// feedback) are the payload, not decoration.
|
||
func actionToolText(label, summary string) string {
|
||
// Newlines are normalised away because the renderer re-wraps to panel width anyway;
|
||
// leaving them in would paint a background stripe per raw line.
|
||
s := strings.Join(strings.Fields(summary), " ")
|
||
if s == "" {
|
||
return label
|
||
}
|
||
// ponytail: flat 4000-char ceiling so one recursive list_dir can't flood the
|
||
// transcript. Swap for expand-on-select against the diff/preview surface if that
|
||
// ceiling starts cutting real output.
|
||
return label + " · " + clip(s, 4000)
|
||
}
|
||
|
||
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, target, 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 target != "" {
|
||
txt += " → " + target
|
||
}
|
||
if strings.HasPrefix(reason, "grant:") {
|
||
txt += " · via grant"
|
||
}
|
||
return txt
|
||
}
|
||
|
||
// previewTarget extracts a short target label from a JSON preview payload
|
||
// (e.g. {"argv":["bash","-c","curl ..."]} → the last argv element, truncated).
|
||
func previewTarget(preview string) string {
|
||
// Naive extraction: find "argv" array and return the last element
|
||
if i := strings.Index(preview, `"argv":`); i >= 0 {
|
||
rest := preview[i+len(`"argv":`):]
|
||
if j := strings.IndexByte(rest, '['); j >= 0 {
|
||
argv := rest[j:]
|
||
if k := strings.IndexByte(argv, ']'); k >= 0 {
|
||
argv = argv[:k+1]
|
||
}
|
||
// Parse comma-separated strings, grab the last non-empty
|
||
parts := strings.Split(strings.Trim(argv, "[]"), ",")
|
||
for i := len(parts) - 1; i >= 0; i-- {
|
||
candidate := strings.Trim(strings.Trim(parts[i], ` "`), `"`)
|
||
if candidate != "" {
|
||
return clip(candidate, 40)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 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
|
||
if !s.named {
|
||
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
|
||
}
|
||
}
|
||
|
||
// onSessionRenamed applies the intent-derived title from session.renamed, replacing the
|
||
// opaque workflow id shown until the naming inference completed. The named flag pins it so a
|
||
// later announce (e.g. on reconnect replay) doesn't revert the display back to the workflow id.
|
||
func (m *Model) onSessionRenamed(msg protocol.ServerMessage) {
|
||
if msg.Name == "" {
|
||
return
|
||
}
|
||
s := m.ensureSession(msg.SessionID)
|
||
s.Name = msg.Name
|
||
s.named = true
|
||
s.LastEventAt = nowMillis()
|
||
}
|
||
|
||
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
|
||
}
|
||
// A newly-arrived clarification must always surface. clarDismissed is model-global, and
|
||
// clarInitState (which clears it) only runs for the selected session — so a clarification
|
||
// arriving for an unselected session while the flag was left set would stay silently hidden.
|
||
m.clarDismissed = false
|
||
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
|
||
}
|
||
m.restoreTranscript(msg.SessionID, msg.Transcript)
|
||
}
|
||
|
||
// restoreTranscript rebuilds the OUTPUT transcript for a reopened session from the snapshot's raw
|
||
// source rows, reusing the same formatters the live path uses (diffSummary/paramSuffix/…). Replaces
|
||
// (not appends) so a reconnect with an existing transcript doesn't duplicate rows.
|
||
func (m *Model) restoreTranscript(sid string, rows []protocol.TranscriptRowDto) {
|
||
if len(rows) == 0 {
|
||
return
|
||
}
|
||
out := make([]RouterEntry, 0, len(rows))
|
||
for _, r := range rows {
|
||
switch r.Kind {
|
||
case "chat":
|
||
role := "router"
|
||
if r.Role == "USER" {
|
||
role = "user"
|
||
}
|
||
e := RouterEntry{Role: role, Content: r.Content}
|
||
if r.LatencyMs != nil && r.TotalTokens != nil {
|
||
e.Metrics = &TurnMetrics{LatencyMs: *r.LatencyMs, TotalTokens: *r.TotalTokens}
|
||
}
|
||
out = append(out, e)
|
||
case "narration":
|
||
e := RouterEntry{Role: "narration_llm", Content: r.Content}
|
||
if r.LatencyMs != nil && r.TotalTokens != nil {
|
||
e.Metrics = &TurnMetrics{LatencyMs: *r.LatencyMs, TotalTokens: *r.TotalTokens}
|
||
}
|
||
out = append(out, e)
|
||
case "thinking":
|
||
if strings.TrimSpace(r.Content) != "" {
|
||
out = append(out, RouterEntry{Role: "thinking", Content: r.Content})
|
||
}
|
||
case "tool":
|
||
out = append(out, toolTranscriptRows(r)...)
|
||
}
|
||
}
|
||
m.routerMessages[sid] = out
|
||
}
|
||
|
||
// toolTranscriptRows mirrors the live TypeToolCompleted/Failed/Rejected handling: a diff becomes a
|
||
// ✎ write row + the collapsed raw-diff row; otherwise a single ✓/✗/✕ action row.
|
||
func toolTranscriptRows(r protocol.TranscriptRowDto) []RouterEntry {
|
||
if r.Diff != nil && *r.Diff != "" {
|
||
path, add, del := diffSummary(*r.Diff)
|
||
return []RouterEntry{
|
||
{Role: "action", Icon: "✎", Content: "wrote " + path + countSuffix(add, del)},
|
||
{Role: "tool", Content: *r.Diff},
|
||
}
|
||
}
|
||
switch r.Status {
|
||
case "failed":
|
||
return []RouterEntry{{Role: "action", Icon: "✗", Content: actionToolText(r.ToolName+" failed", r.Summary)}}
|
||
case "rejected":
|
||
return []RouterEntry{{Role: "action", Icon: "✕", Content: actionToolText(r.ToolName+" blocked", r.Summary)}}
|
||
default:
|
||
label := r.ToolName
|
||
if suffix := paramSuffix(r.Params); suffix != "" {
|
||
label += suffix
|
||
} else if len(r.AffectedEntities) > 0 {
|
||
label += " " + strings.Join(r.AffectedEntities, ", ")
|
||
}
|
||
return []RouterEntry{{Role: "action", Icon: "✓", Content: actionToolText(label, r.Summary)}}
|
||
}
|
||
}
|
||
|
||
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) }
|