diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 31ff58ed..cc0cb440 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -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" diff --git a/apps/tui-go/internal/app/help_complete_test.go b/apps/tui-go/internal/app/help_complete_test.go index 4f995079..0489f517 100644 --- a/apps/tui-go/internal/app/help_complete_test.go +++ b/apps/tui-go/internal/app/help_complete_test.go @@ -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 diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index ebce1517..5e8dc0e2 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -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 diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index a9655a43..c648f457 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -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)"}, diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index e3486dcf..61d5b8af 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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 diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 3ff10db0..ff7f4a9d 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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 · , Tab-cycled.