From d6155691c8641464f10da410fb6b102e1d21e4a5 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 21 Jul 2026 11:52:00 +0400 Subject: [PATCH] feat(tui): token metrics aggregation, plan overlay, tool reasoning, clar dismissal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #295 — include narration_llm tokens in changes-panel aggregate count #296 — execution plan overlay (Ctrl+P in-session) #298 — surface reasoning/CoT on tool-call turns in output view #265 — dismiss clarification modal when answered externally (session.resumed) --- apps/tui-go/internal/app/key.go | 3 ++ apps/tui-go/internal/app/model.go | 14 ++++-- apps/tui-go/internal/app/overlays.go | 68 ++++++++++++++++++++++++++++ apps/tui-go/internal/app/server.go | 8 +++- apps/tui-go/internal/app/update.go | 9 ++++ apps/tui-go/internal/app/view.go | 19 +++++++- 6 files changed, 115 insertions(+), 6 deletions(-) diff --git a/apps/tui-go/internal/app/key.go b/apps/tui-go/internal/app/key.go index ad65323e..7d90d513 100644 --- a/apps/tui-go/internal/app/key.go +++ b/apps/tui-go/internal/app/key.go @@ -31,6 +31,7 @@ const ( keyCtrlR keyCtrlU keyCtrlX + keyCtrlP keyCtrlUp keyCtrlDown keyOther @@ -99,6 +100,8 @@ func toKeyMsg(p tea.KeyPressMsg) keyMsg { out.Type = keyCtrlC case 'd': out.Type = keyCtrlD + case 'p': + out.Type = keyCtrlP case 'j': out.Type = keyCtrlJ case 'r': diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 2fc9b941..a384591b 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -66,14 +66,16 @@ const ( 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 + Role string // user | router | tool | narration | narration_llm | action + Content string + Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟) + Metrics *TurnMetrics + Reasoning string // model reasoning/CoT that preceded this entry (tool/artifact turns) } // TurnMetrics carries optional latency + token cost for a ROUTER chat turn. @@ -440,6 +442,10 @@ type Model struct { // (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back. outputScroll int + // lastReasoning holds the model's reasoning/CoT trace from the most recent + // inference.completed, surfaced on the following tool-call and artifact rows. + lastReasoning string + // 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 diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 41b0678b..4a88bb24 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -141,6 +141,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.grantsModal()) case OverlayGrantScope: return m.center(m.grantScopeModal()) + case OverlayPlan: + return m.center(m.planModal()) case OverlayHelp: return m.center(m.helpModal()) } @@ -926,6 +928,72 @@ func (m Model) toolPaletteModal() string { return m.center(modal) } +func (m Model) planModal() string { + t := m.theme + w := m.modalWidth() + s := m.session(m.selectedID) + + var b strings.Builder + b.WriteString(m.titleLine("execution plan")) + if s != nil { + b.WriteString(mbg(t, " — "+s.WorkflowID, t.P.Dim)) + } + b.WriteString("\n\n") + + if s == nil || len(s.PlanStages) == 0 { + b.WriteString(mbg(t, " no execution plan yet — stages appear once the plan is locked", t.P.Faint) + "\n") + if s != nil && s.CurrentStage != "" { + b.WriteString(mbg(t, " current stage: "+s.CurrentStage, t.P.Accent2) + "\n") + } + } else { + goal := strings.TrimSpace(s.PlanGoal) + if goal != "" { + b.WriteString(mbg(t, " goal", t.P.Faint) + "\n") + for _, ln := range wrap(goal, w-8) { + b.WriteString(mbg(t, " "+ln, t.P.Fg) + "\n") + } + b.WriteString("\n") + } + + b.WriteString(mbg(t, " stages ("+itoa(len(s.PlanStages))+" total)", t.P.Faint) + "\n") + idW := 0 + for _, ps := range s.PlanStages { + if len(ps.ID) > idW { + idW = len(ps.ID) + } + } + for _, ps := range s.PlanStages { + var badge string + switch ps.Status { + case PlanPending: + badge = mbg(t, " ○ pending", t.P.Faint) + case PlanRunning: + badge = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(" ● running") + case PlanCompleted: + badge = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render(" ✓ done") + case PlanFailed: + badge = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ✗ failed") + } + stageLine := mbg(t, " ", t.P.BgPanel) + badge + mbg(t, " "+padRaw(ps.ID, idW), t.P.Fg) + if s.ToolsByStage != nil { + if tools, ok := s.ToolsByStage[ps.ID]; ok && len(tools) > 0 { + stageLine += mbg(t, " ["+itoa(len(tools))+" tools]", t.P.Faint) + } + } + if budget, ok := s.TokenBudgetByStage[ps.ID]; ok && budget > 0 { + used := 0 + if ps.ID == s.CurrentStage { + used = s.StageTokensUsed + } + stageLine += mbg(t, " ("+itoa(used)+"/"+itoa(budget)+" tok)", t.P.Faint) + } + b.WriteString(stageLine + "\n") + } + } + b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) +} + func (m Model) modelsModal() string { t := m.theme w := m.modalWidth() diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 6a193237..32e47a04 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -136,8 +136,12 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { 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 { @@ -190,6 +194,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } } case protocol.TypeInferenceStarted: + m.lastReasoning = "" if s := m.session(msg.SessionID); s != nil { s.Active = true s.addEvent(nowMillis(), "InferenceStarted", msg.StageID) @@ -209,6 +214,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { // 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}) + m.lastReasoning = msg.Reasoning } } case protocol.TypeInferenceTimeout: @@ -249,7 +255,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { // 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}) + m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff, Reasoning: m.lastReasoning}) } else { label := msg.ToolName // Prefer the actual call args (path=…, command="…") over the affected-entities diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 9ca01984..e03a3aee 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -287,6 +287,11 @@ func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) { m.diffScrollOffset = 0 } return m, nil + case keyCtrlP: + if ds == StateInSession { + m.overlay = OverlayPlan + } + return m, nil } if k.Type != keyRunes { return m, nil @@ -855,6 +860,10 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) { if runeIs(k, "H") { m.overlay = OverlayNone } + case OverlayPlan: + if k.Type == keyCtrlP || runeIs(k, "p") { + m.overlay = OverlayNone + } case OverlayIdeas: switch { case k.Type == keyUp || runeIs(k, "k"): diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 84eaecf4..eb2ebaa8 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -402,7 +402,7 @@ func (m Model) changesRows(w, h int) []string { t := m.theme turns, toks := 0, 0 for _, e := range m.routerMessages[m.selectedID] { - if e.Role == "router" && e.Metrics != nil { + if (e.Role == "router" || e.Role == "narration_llm") && e.Metrics != nil { turns++ toks += e.Metrics.TotalTokens } @@ -822,6 +822,23 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) { rows = append(rows, t.span(s, t.P.Faint)) } case "tool": + if e.Reasoning != "" { + if m.thinkingShown { + for _, ln := range strings.Split(strings.TrimRight(e.Reasoning, "\n"), "\n") { + rows = append(rows, t.span("✼ "+ln, t.P.Faint)) + } + } else { + rows = append(rows, t.span("✼ reasoning — palette: thinking", t.P.Faint)) + } + } else if m.lastReasoning != "" { + if m.thinkingShown { + for _, ln := range strings.Split(strings.TrimRight(m.lastReasoning, "\n"), "\n") { + rows = append(rows, t.span("✼ "+ln, t.P.Faint)) + } + } else { + rows = append(rows, t.span("✼ reasoning — palette: thinking", t.P.Faint)) + } + } rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim)) case "thinking": // Collapsed by default to a single muted line so the reasoning trace doesn't bury