feat(tui): token metrics aggregation, plan overlay, tool reasoning, clar dismissal
#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)
This commit is contained in:
@@ -31,6 +31,7 @@ const (
|
|||||||
keyCtrlR
|
keyCtrlR
|
||||||
keyCtrlU
|
keyCtrlU
|
||||||
keyCtrlX
|
keyCtrlX
|
||||||
|
keyCtrlP
|
||||||
keyCtrlUp
|
keyCtrlUp
|
||||||
keyCtrlDown
|
keyCtrlDown
|
||||||
keyOther
|
keyOther
|
||||||
@@ -99,6 +100,8 @@ func toKeyMsg(p tea.KeyPressMsg) keyMsg {
|
|||||||
out.Type = keyCtrlC
|
out.Type = keyCtrlC
|
||||||
case 'd':
|
case 'd':
|
||||||
out.Type = keyCtrlD
|
out.Type = keyCtrlD
|
||||||
|
case 'p':
|
||||||
|
out.Type = keyCtrlP
|
||||||
case 'j':
|
case 'j':
|
||||||
out.Type = keyCtrlJ
|
out.Type = keyCtrlJ
|
||||||
case 'r':
|
case 'r':
|
||||||
|
|||||||
@@ -66,14 +66,16 @@ const (
|
|||||||
OverlayHelp
|
OverlayHelp
|
||||||
OverlayProjectProfile
|
OverlayProjectProfile
|
||||||
OverlayOperatorProfile
|
OverlayOperatorProfile
|
||||||
|
OverlayPlan
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// RouterEntry is one line in a session's conversation transcript.
|
||||||
type RouterEntry struct {
|
type RouterEntry struct {
|
||||||
Role string // user | router | tool | narration | narration_llm | action
|
Role string // user | router | tool | narration | narration_llm | action
|
||||||
Content string
|
Content string
|
||||||
Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟)
|
Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟)
|
||||||
Metrics *TurnMetrics
|
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.
|
// 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.
|
// (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back.
|
||||||
outputScroll int
|
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
|
// 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 /.
|
// of type/detail. eventFilterTyping is true while the operator is editing the query after /.
|
||||||
eventFilter string
|
eventFilter string
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.grantsModal())
|
return m.center(m.grantsModal())
|
||||||
case OverlayGrantScope:
|
case OverlayGrantScope:
|
||||||
return m.center(m.grantScopeModal())
|
return m.center(m.grantScopeModal())
|
||||||
|
case OverlayPlan:
|
||||||
|
return m.center(m.planModal())
|
||||||
case OverlayHelp:
|
case OverlayHelp:
|
||||||
return m.center(m.helpModal())
|
return m.center(m.helpModal())
|
||||||
}
|
}
|
||||||
@@ -926,6 +928,72 @@ func (m Model) toolPaletteModal() string {
|
|||||||
return m.center(modal)
|
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 {
|
func (m Model) modelsModal() string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
w := m.modalWidth()
|
w := m.modalWidth()
|
||||||
|
|||||||
@@ -136,8 +136,12 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Status = "ACTIVE"
|
s.Status = "ACTIVE"
|
||||||
s.clearApprovals()
|
s.clearApprovals()
|
||||||
|
s.Clar = nil
|
||||||
s.LastEventAt = nowMillis()
|
s.LastEventAt = nowMillis()
|
||||||
}
|
}
|
||||||
|
if msg.SessionID == m.selectedID {
|
||||||
|
m.clarResetState()
|
||||||
|
}
|
||||||
case protocol.TypeSessionCompleted:
|
case protocol.TypeSessionCompleted:
|
||||||
m.touch(msg.SessionID, "COMPLETED")
|
m.touch(msg.SessionID, "COMPLETED")
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
@@ -190,6 +194,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case protocol.TypeInferenceStarted:
|
case protocol.TypeInferenceStarted:
|
||||||
|
m.lastReasoning = ""
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Active = true
|
s.Active = true
|
||||||
s.addEvent(nowMillis(), "InferenceStarted", msg.StageID)
|
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.
|
// row (revealed via the palette). Skipped when the model emits no separate channel.
|
||||||
if strings.TrimSpace(msg.Reasoning) != "" {
|
if strings.TrimSpace(msg.Reasoning) != "" {
|
||||||
m.appendRouter(msg.SessionID, RouterEntry{Role: "thinking", Content: msg.Reasoning})
|
m.appendRouter(msg.SessionID, RouterEntry{Role: "thinking", Content: msg.Reasoning})
|
||||||
|
m.lastReasoning = msg.Reasoning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case protocol.TypeInferenceTimeout:
|
case protocol.TypeInferenceTimeout:
|
||||||
@@ -249,7 +255,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
// existing collapsed diff row (^x opens the full diff).
|
// existing collapsed diff row (^x opens the full diff).
|
||||||
path, add, del := diffSummary(*msg.Diff)
|
path, add, del := diffSummary(*msg.Diff)
|
||||||
m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del))
|
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 {
|
} else {
|
||||||
label := msg.ToolName
|
label := msg.ToolName
|
||||||
// Prefer the actual call args (path=…, command="…") over the affected-entities
|
// Prefer the actual call args (path=…, command="…") over the affected-entities
|
||||||
|
|||||||
@@ -287,6 +287,11 @@ func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.diffScrollOffset = 0
|
m.diffScrollOffset = 0
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
case keyCtrlP:
|
||||||
|
if ds == StateInSession {
|
||||||
|
m.overlay = OverlayPlan
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
if k.Type != keyRunes {
|
if k.Type != keyRunes {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -855,6 +860,10 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if runeIs(k, "H") {
|
if runeIs(k, "H") {
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
}
|
}
|
||||||
|
case OverlayPlan:
|
||||||
|
if k.Type == keyCtrlP || runeIs(k, "p") {
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
}
|
||||||
case OverlayIdeas:
|
case OverlayIdeas:
|
||||||
switch {
|
switch {
|
||||||
case k.Type == keyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ func (m Model) changesRows(w, h int) []string {
|
|||||||
t := m.theme
|
t := m.theme
|
||||||
turns, toks := 0, 0
|
turns, toks := 0, 0
|
||||||
for _, e := range m.routerMessages[m.selectedID] {
|
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++
|
turns++
|
||||||
toks += e.Metrics.TotalTokens
|
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))
|
rows = append(rows, t.span(s, t.P.Faint))
|
||||||
}
|
}
|
||||||
case "tool":
|
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))
|
rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim))
|
||||||
case "thinking":
|
case "thinking":
|
||||||
// Collapsed by default to a single muted line so the reasoning trace doesn't bury
|
// Collapsed by default to a single muted line so the reasoning trace doesn't bury
|
||||||
|
|||||||
Reference in New Issue
Block a user