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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user