feat(tui-go): in-session right panel cycles events / changes / off (d)

Press d (or the "panel" palette command) in-session to cycle the right panel:
- events  — the live event stream (default, unchanged)
- changes — a token-usage line (tokens + turns, summed from the transcript's
            TurnMetrics) over a git-status-style list of files the session has
            written, each with summed +/− from its diff. ^x still opens the full
            diff.
- off     — hide the panel; the output transcript takes the full width.

changesRows derives everything from data already in the model (router-turn
metrics + the tool entries' unified diffs), so no new protocol. Footer + ? help
updated; "changes" preview kind added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:47:23 +00:00
parent cde0c33031
commit 6c792b83e2
6 changed files with 95 additions and 3 deletions
+18
View File
@@ -22,6 +22,24 @@ func PreviewFrame(kind string, w, h int) string {
m.bgUpdates = 3 m.bgUpdates = 3
m.selectedID = "04a546aa" m.selectedID = "04a546aa"
case "changes":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.rightPanel = 1
if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "write_script"
}
m.routerMessages["04a546aa"] = []RouterEntry{
{Role: "user", Content: "add a healthcheck script and document it"},
{Role: "router", Content: "I'll write the script.", Metrics: &TurnMetrics{LatencyMs: 1200, TotalTokens: 340}},
{Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf localhost:8080/health\n+echo ok\n+exit 0\n"},
{Role: "router", Content: "Now the docs.", Metrics: &TurnMetrics{LatencyMs: 820, TotalTokens: 150}},
{Role: "tool", Content: "--- a/README.md\n+++ b/README.md\n@@ -1,1 +1,2 @@\n existing line\n+## Health\n"},
}
case "compose": case "compose":
m.connected = true m.connected = true
m.currentModel = "llama-cpp:default" m.currentModel = "llama-cpp:default"
@@ -25,6 +25,7 @@ func TestHelpCoversEveryKeybind(t *testing.T) {
"back to the session list", // l "back to the session list", // l
// session // session
"compose a message", // i "compose a message", // i
"right panel: events / changes", // d
"toggle chat / steering mode", // s "toggle chat / steering mode", // s
"show workflows", // w "show workflows", // w
"copy the selected message", // y "copy the selected message", // y
+3
View File
@@ -278,6 +278,9 @@ type Model struct {
diffScrollOffset int diffScrollOffset int
modalScroll int // body scroll for tall fixed-content modals (help, stats) modalScroll int // body scroll for tall fixed-content modals (help, stats)
eventStripShown bool eventStripShown bool
// in-session right panel: 0 = events, 1 = changes (token usage + changed files),
// 2 = off (output full-width). Cycled with `d`.
rightPanel int
// artifact viewer (OverlayArtifacts) — populated by the artifact.list response // artifact viewer (OverlayArtifacts) — populated by the artifact.list response
artifacts []protocol.ArtifactDto artifacts []protocol.ArtifactDto
+1
View File
@@ -830,6 +830,7 @@ func (m Model) helpBody() []string {
}) })
section("session", []kb{ section("session", []kb{
{"i", "compose a message"}, {"i", "compose a message"},
{"d", "right panel: events / changes / off"},
{"s", "toggle chat / steering mode"}, {"s", "toggle chat / steering mode"},
{"w", "show workflows (idle screen)"}, {"w", "show workflows (idle screen)"},
{"y", "copy the selected message (OSC52)"}, {"y", "copy the selected message (OSC52)"},
+13
View File
@@ -310,6 +310,11 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if ds == StateIdle { if ds == StateIdle {
m.cycleLauncherWf() m.cycleLauncherWf()
} }
case "d":
// In-session: cycle the right panel (events → changes → off).
if ds == StateInSession {
m.cycleRightPanel()
}
case "l": case "l":
if ds != StateIdle { if ds != StateIdle {
m.sessionEntered = false m.sessionEntered = false
@@ -1090,6 +1095,7 @@ func paletteCommands() []paletteCmd {
{"grants", "G", "grants", "view / revoke standing grants"}, {"grants", "G", "grants", "view / revoke standing grants"},
{"statusbar", "", "status bar", "show / hide status-bar segments"}, {"statusbar", "", "status bar", "show / hide status-bar segments"},
{"rail", "", "idle rail", "show / hide the idle status + keys rail"}, {"rail", "", "idle rail", "show / hide the idle status + keys rail"},
{"panel", "d", "right panel", "in-session: events → changes → off"},
{"actions", "", "inline actions", "show / hide tool & action rows in output"}, {"actions", "", "inline actions", "show / hide tool & action rows in output"},
{"help", "?", "help", "keybinding cheat-sheet"}, {"help", "?", "help", "keybinding cheat-sheet"},
{"mode", "s", "toggle mode", "switch chat / steering"}, {"mode", "s", "toggle mode", "switch chat / steering"},
@@ -1140,6 +1146,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
m.sbIndex = 0 m.sbIndex = 0
case "rail": case "rail":
m.railHidden = !m.railHidden m.railHidden = !m.railHidden
case "panel":
m.cycleRightPanel()
case "actions": case "actions":
m.toggleInlineActions() m.toggleInlineActions()
case "help": case "help":
@@ -1187,6 +1195,11 @@ func (m *Model) cycleLauncherWf() {
m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1) m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1)
} }
// cycleRightPanel advances the in-session right panel: events → changes → off → events.
func (m *Model) cycleRightPanel() {
m.rightPanel = (m.rightPanel + 1) % 3
}
func (m *Model) cycleChatMode() { func (m *Model) cycleChatMode() {
if m.chatMode == ChatModeChat { if m.chatMode == ChatModeChat {
m.chatMode = ChatModeSteering m.chatMode = ChatModeSteering
+59 -3
View File
@@ -261,7 +261,7 @@ func (m Model) renderFooter() string {
} else { } else {
// Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`, // Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`,
// so the footer keeps the high-traffic keys + the new transcript nav/copy. // so the footer keeps the high-traffic keys + the new transcript nav/copy.
hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("d", "panel"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
} }
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied")) hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))
@@ -295,12 +295,68 @@ func (m Model) renderMain(h int) string {
leftW := m.width * 63 / 100 leftW := m.width * 63 / 100
rightW := m.width - leftW rightW := m.width - leftW
// In-session / approval: output transcript over the event stream. // In-session / approval. The right panel cycles (d): events, changes (token usage +
// changed files), or off (output takes the full width).
if m.rightPanel == 2 {
return m.theme.box("output", m.routerRows(m.width-4, h-2), m.width, h, true)
}
left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true) left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
right := m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false) var right string
if m.rightPanel == 1 {
right = m.theme.box("changes", m.changesRows(rightW-4, h), rightW, h, false)
} else {
right = m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false)
}
return lipgloss.JoinHorizontal(lipgloss.Top, left, right) return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
} }
// changesRows is the in-session "changes" panel: a token-usage line plus a git-status-style
// list of files the session has written (summed +/- from each write's diff). ^x opens the
// full diff. Drives the right panel when rightPanel == 1.
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 {
turns++
toks += e.Metrics.TotalTokens
}
}
rows := []string{
t.span("tokens ", t.P.Faint) + t.span(itoa(toks), t.P.FgStrong) +
t.span(" · turns ", t.P.Faint) + t.span(itoa(turns), t.P.Dim),
"",
}
type chg struct{ add, del int }
var order []string
seen := map[string]*chg{}
for _, e := range m.routerMessages[m.selectedID] {
if e.Role != "tool" || !isUnifiedDiff(e.Content) {
continue
}
p, a, d := diffSummary(e.Content)
if seen[p] == nil {
seen[p] = &chg{}
order = append(order, p)
}
seen[p].add += a
seen[p].del += d
}
if len(order) == 0 {
return append(rows, t.span("no file changes yet", t.P.Faint))
}
rows = append(rows, t.span("changes (^x for full diff)", t.P.Faint))
for _, p := range order {
c := seen[p]
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("✎ ")
delta := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("+"+itoa(c.add)) +
t.span(" ", t.P.Bg) + lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render(""+itoa(c.del))
rows = append(rows, icon+t.span(truncate(p, w-16), t.P.Fg)+" "+delta)
}
return rows
}
// renderLauncher is the idle screen: a compact, opencode-style input centered in the main // renderLauncher is the idle screen: a compact, opencode-style input centered in the main
// area, with a hideable right rail of status + quick keys. The session list isn't shown — // area, with a hideable right rail of status + quick keys. The session list isn't shown —
// it's a keypress away (R). The input's lower-right shows <workflow> · <model>, Tab-cycled. // it's a keypress away (R). The input's lower-right shows <workflow> · <model>, Tab-cycled.