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.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":
|
||||
m.connected = true
|
||||
m.currentModel = "llama-cpp:default"
|
||||
|
||||
@@ -25,6 +25,7 @@ func TestHelpCoversEveryKeybind(t *testing.T) {
|
||||
"back to the session list", // l
|
||||
// session
|
||||
"compose a message", // i
|
||||
"right panel: events / changes", // d
|
||||
"toggle chat / steering mode", // s
|
||||
"show workflows", // w
|
||||
"copy the selected message", // y
|
||||
|
||||
@@ -278,6 +278,9 @@ type Model struct {
|
||||
diffScrollOffset int
|
||||
modalScroll int // body scroll for tall fixed-content modals (help, stats)
|
||||
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
|
||||
artifacts []protocol.ArtifactDto
|
||||
|
||||
@@ -830,6 +830,7 @@ func (m Model) helpBody() []string {
|
||||
})
|
||||
section("session", []kb{
|
||||
{"i", "compose a message"},
|
||||
{"d", "right panel: events / changes / off"},
|
||||
{"s", "toggle chat / steering mode"},
|
||||
{"w", "show workflows (idle screen)"},
|
||||
{"y", "copy the selected message (OSC52)"},
|
||||
|
||||
@@ -310,6 +310,11 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if ds == StateIdle {
|
||||
m.cycleLauncherWf()
|
||||
}
|
||||
case "d":
|
||||
// In-session: cycle the right panel (events → changes → off).
|
||||
if ds == StateInSession {
|
||||
m.cycleRightPanel()
|
||||
}
|
||||
case "l":
|
||||
if ds != StateIdle {
|
||||
m.sessionEntered = false
|
||||
@@ -1090,6 +1095,7 @@ func paletteCommands() []paletteCmd {
|
||||
{"grants", "G", "grants", "view / revoke standing grants"},
|
||||
{"statusbar", "", "status bar", "show / hide status-bar segments"},
|
||||
{"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"},
|
||||
{"help", "?", "help", "keybinding cheat-sheet"},
|
||||
{"mode", "s", "toggle mode", "switch chat / steering"},
|
||||
@@ -1140,6 +1146,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
||||
m.sbIndex = 0
|
||||
case "rail":
|
||||
m.railHidden = !m.railHidden
|
||||
case "panel":
|
||||
m.cycleRightPanel()
|
||||
case "actions":
|
||||
m.toggleInlineActions()
|
||||
case "help":
|
||||
@@ -1187,6 +1195,11 @@ func (m *Model) cycleLauncherWf() {
|
||||
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() {
|
||||
if m.chatMode == ChatModeChat {
|
||||
m.chatMode = ChatModeSteering
|
||||
|
||||
@@ -261,7 +261,7 @@ func (m Model) renderFooter() string {
|
||||
} else {
|
||||
// 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.
|
||||
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 {
|
||||
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
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user