Files
correx/apps/tui-go/internal/app/server.go
T
kami 6bee3a7d31 fix(tui-go): render event/activity times in local time, not UTC
formatTime forced .UTC(), so every event-log timestamp and the welcome "recent"
times read hours off the operator's wall clock — and inconsistent with the local
date just added to the session list. time.UnixMilli is already local, so the
explicit .UTC() was the bug; drop it. No test asserts a clock string (the render
matrix only checks event type/detail), so this is display-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:14:51 +00:00

662 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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) }