From 531910d38a9edfb671512f17d6f4356b4eab9f7f Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 05:41:38 +0000 Subject: [PATCH] feat(tui-go): transcript free-scroll + help overlay + event filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three TUI-polish items. - OUTPUT free-scroll: PgUp/PgDn (and ^u/^d half-page) scroll the transcript back through history; esc snaps to tail-follow. outputScroll is clamped against outputViewport(), which mirrors View/renderMain geometry, so the edges land exactly (no dead presses unwinding an overshoot). routerRows split into buildTranscriptRows (full render) + windowing so the handler measures the same content. Tests cover the clamp + the fits-in-panel no-op. - Help overlay: `?` (or the "help" palette command) opens a keybinding cheat-sheet grouped by context (session / overlays / approval band) — the non-palette keys especially. Any key dismisses it. - Event-inspector filter: `/` filters the event list by a case-insensitive substring of type/detail (currentEvents applies it); `c` clears, esc stops editing then closes. Fresh per open. EVENTS side panel stays unfiltered. go build / vet / test green; rendered help + filtered inspector via cmd/preview (help, events-filter kinds). Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 21 +++ apps/tui-go/internal/app/model.go | 10 ++ .../tui-go/internal/app/output_scroll_test.go | 41 +++++ apps/tui-go/internal/app/overlays.go | 69 ++++++++- apps/tui-go/internal/app/update.go | 143 +++++++++++++++++- apps/tui-go/internal/app/view.go | 53 ++++--- 6 files changed, 309 insertions(+), 28 deletions(-) create mode 100644 apps/tui-go/internal/app/output_scroll_test.go diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 796a6104..4da069c6 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -184,6 +184,27 @@ func PreviewFrame(kind string, w, h int) string { s.LastEventAt = nowMillis() - 3000 } + case "help": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.overlay = OverlayHelp + + case "events-filter": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + if s := m.session("04a546aa"); s != nil { + s.Events = sampleEvents() + } + m.overlay = OverlayEventInspector + m.eventFilter = "tool" + m.eventFilterTyping = true + case "grants": m.connected = true m.currentModel = "llama-cpp:default" diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 478a42bc..37d240e4 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -61,6 +61,7 @@ const ( OverlayStatusbar OverlayGrants OverlayGrantScope + OverlayHelp ) // RouterEntry is one line in a session's conversation transcript. @@ -327,6 +328,15 @@ type Model struct { // the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json. actionsHidden bool + // outputScroll is how many rows up from the bottom the OUTPUT transcript is scrolled + // (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back. + outputScroll int + + // 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 /. + eventFilter string + eventFilterTyping bool + // standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply. grants []protocol.GrantDto grantsLoading bool diff --git a/apps/tui-go/internal/app/output_scroll_test.go b/apps/tui-go/internal/app/output_scroll_test.go new file mode 100644 index 00000000..240bfc82 --- /dev/null +++ b/apps/tui-go/internal/app/output_scroll_test.go @@ -0,0 +1,41 @@ +package app + +import "testing" + +// scrollOutput must clamp to [0, totalRows-panelHeight] against the same geometry the renderer +// windows to — so PgUp past the top and PgDn past the bottom land exactly on the edges (no +// dead presses unwinding an overshoot). +func TestOutputScrollClampsToBounds(t *testing.T) { + m := inSessionModel(120, 30) + msgs := make([]RouterEntry, 0, 60) + for i := 0; i < 60; i++ { + msgs = append(msgs, RouterEntry{Role: "router", Content: "a transcript line"}) + } + m.routerMessages[m.selectedID] = msgs + + w, h := m.outputViewport() + rows, _ := m.buildTranscriptRows(w) + max := len(rows) - h + if max <= 0 { + t.Fatalf("fixture transcript (%d rows) must exceed panel height (%d) to test scrolling", len(rows), h) + } + + m.scrollOutput(10_000) // way past the top + if m.outputScroll != max { + t.Fatalf("scroll up clamped to %d, want max %d", m.outputScroll, max) + } + m.scrollOutput(-10_000) // way past the bottom + if m.outputScroll != 0 { + t.Fatalf("scroll down clamped to %d, want 0 (tail-follow)", m.outputScroll) + } +} + +// A transcript that fits the panel never scrolls (stays tail-following). +func TestOutputScrollNoOpWhenFits(t *testing.T) { + m := inSessionModel(120, 30) + m.routerMessages[m.selectedID] = []RouterEntry{{Role: "router", Content: "one line"}} + m.scrollOutput(5) + if m.outputScroll != 0 { + t.Fatalf("short transcript should not scroll, got %d", m.outputScroll) + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 38fc79ad..634d860d 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -132,6 +132,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.grantsModal()) case OverlayGrantScope: return m.center(m.grantScopeModal()) + case OverlayHelp: + return m.center(m.helpModal()) } return base } @@ -437,8 +439,25 @@ func (m Model) eventInspectorModal() string { b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint)) } b.WriteString("\n\n") + // Filter line (shown while editing or when a query is set): a `/` prompt over the query. + if m.eventFilterTyping || m.eventFilter != "" { + caret := mbg(t, "", t.P.BgPanel) + if m.eventFilterTyping && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ") + if m.eventFilter == "" { + b.WriteString(prompt + caret + mbg(t, "type to filter events…", t.P.Faint) + "\n\n") + } else { + b.WriteString(prompt + mbg(t, m.eventFilter, t.P.FgStrong) + caret + "\n\n") + } + } if len(evs) == 0 { - b.WriteString(mbg(t, "no events", t.P.Faint)) + msg := "no events" + if m.eventFilter != "" { + msg = "no events match \"" + m.eventFilter + "\"" + } + b.WriteString(mbg(t, msg, t.P.Faint)) return m.center(t.Overlay.Width(w).Render(b.String())) } // Budget the plain detail so the styled row never needs ANSI-aware truncation @@ -482,7 +501,7 @@ func (m Model) eventInspectorModal() string { mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim) b.WriteString(row + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"/", "filter"}, {"c", "clear"}, {"esc", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) @@ -688,6 +707,52 @@ func (m Model) grantScopeModal() string { return m.center(modal) } +// helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it. +func (m Model) helpModal() string { + t := m.theme + w := m.modalWidth() + + type kb struct{ key, desc string } + section := func(b *strings.Builder, title string, rows []kb) { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title) + "\n") + for _, r := range rows { + key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16)) + b.WriteString(" " + key + mbg(t, r.desc, t.P.Fg) + "\n") + } + b.WriteString("\n") + } + + var b strings.Builder + b.WriteString(m.titleLine("keybindings") + "\n\n") + section(&b, "session", []kb{ + {"i", "compose a message"}, + {"^↑ / ^↓", "jump to your previous / next message"}, + {"y", "copy the selected message (OSC52)"}, + {"PgUp / PgDn", "scroll output (^u / ^d half-page)"}, + {"esc", "drop selection / scroll → follow newest"}, + {"^x", "open the latest diff"}, + {"l", "back to the session list"}, + }) + section(&b, "overlays", []kb{ + {"p", "command palette (shows all shortcuts)"}, + {"e", "event inspector (/ to filter)"}, + {"t / v", "tools / artifacts"}, + {"S / R", "session stats / resume sessions"}, + {"m / g", "swap model / edit config"}, + {"G / I", "grants / idea board"}, + }) + section(&b, "approval band", []kb{ + {"y / a / enter", "approve once"}, + {"n / r", "reject"}, + {"e / s", "steer (add a note)"}, + {"A", "approve-always — choose scope"}, + {"↑ / ↓", "walk the pending queue"}, + }) + b.WriteString(modalHints(t, [][2]string{{"any key", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + func (m Model) toolPaletteModal() string { t := m.theme w := m.modalWidth() diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index b807f9f0..30cf2cea 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -231,14 +231,30 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: return m.normalEnter() case tea.KeyEsc: - // In-session, esc first drops a transcript selection (back to tail-follow); - // otherwise it clears the session-list filter. + // In-session, esc first drops a transcript selection or output scroll (back to + // tail-follow); otherwise it clears the session-list filter. if m.transcriptSel >= 0 { m.transcriptSel = -1 return m, nil } + if m.outputScroll != 0 { + m.outputScroll = 0 + return m, nil + } m.filter = "" return m, nil + case tea.KeyPgUp: + m.scrollOutput(m.outputViewportPage()) + return m, nil + case tea.KeyPgDown: + m.scrollOutput(-m.outputViewportPage()) + return m, nil + case tea.KeyCtrlU: + m.scrollOutput(m.outputViewportPage() / 2) + return m, nil + case tea.KeyCtrlD: + m.scrollOutput(-m.outputViewportPage() / 2) + return m, nil case tea.KeyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff @@ -252,6 +268,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { switch string(k.Runes) { case "i": m.enterInsert(ModeRouter) + case "?": + m.overlay = OverlayHelp case "/": m.enterInsert(ModeFilter) case "j": @@ -261,8 +279,7 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "p": m.openPalette() case "e": - m.overlay = OverlayEventInspector - m.overlayEventIdx = 0 + m.openEventInspector() case "t": m.overlay = OverlayToolPalette case "v": @@ -600,6 +617,12 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleSessionsKey(k) } if k.Type == tea.KeyEsc { + // In the event inspector, esc first stops an in-progress filter edit (keeping the + // query) rather than closing the overlay. + if m.overlay == OverlayEventInspector && m.eventFilterTyping { + m.eventFilterTyping = false + return m, nil + } m.overlay = OverlayNone return m, nil } @@ -621,7 +644,27 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayEventInspector: evs := m.currentEvents() + if m.eventFilterTyping { + switch { + case k.Type == tea.KeyEnter: + m.eventFilterTyping = false + case k.Type == tea.KeyBackspace: + if n := len(m.eventFilter); n > 0 { + m.eventFilter = m.eventFilter[:n-1] + m.overlayEventIdx = 0 + } + case k.Type == tea.KeyRunes || k.Type == tea.KeySpace: + m.eventFilter += string(k.Runes) + m.overlayEventIdx = 0 + } + return m, nil + } switch { + case runeIs(k, "/"): + m.eventFilterTyping = true + case runeIs(k, "c"): + m.eventFilter = "" + m.overlayEventIdx = 0 case k.Type == tea.KeyUp || runeIs(k, "k"): if m.overlayEventIdx > 0 { m.overlayEventIdx-- @@ -631,6 +674,9 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.overlayEventIdx++ } } + case OverlayHelp: + // Any key dismisses the cheat-sheet (esc handled above). + m.overlay = OverlayNone case OverlayToolPalette: if runeIs(k, "t") { m.overlay = OverlayNone @@ -973,6 +1019,7 @@ func paletteCommands() []paletteCmd { {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, {"actions", "", "inline actions", "show / hide tool & action rows in output"}, + {"help", "?", "help", "keybinding cheat-sheet"}, {"mode", "s", "toggle mode", "switch chat / steering"}, {"cancel", "c", "cancel session", "stop the selected session"}, {"back", "l", "back to list", "leave the current session"}, @@ -1005,8 +1052,7 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { case "models": m.openModelsOverlay() case "events": - m.overlay = OverlayEventInspector - m.overlayEventIdx = 0 + m.openEventInspector() case "artifacts": m.openArtifacts() case "stats": @@ -1022,6 +1068,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.sbIndex = 0 case "actions": m.toggleInlineActions() + case "help": + m.overlay = OverlayHelp case "mode": m.cycleChatMode() case "cancel": @@ -1310,6 +1358,64 @@ func (m *Model) toggleInlineActions() { saveInlineActionsHidden(m.actionsHidden) } +// scrollOutput moves the OUTPUT transcript by delta rows (positive = up / back through history), +// clamped to the transcript height against the same geometry the renderer uses. A 0 delta or a +// transcript shorter than the panel is a no-op (stays tail-following). +func (m *Model) scrollOutput(delta int) { + if delta == 0 { + return + } + w, h := m.outputViewport() + rows, _ := m.buildTranscriptRows(w) + max := len(rows) - h + if max < 0 { + max = 0 + } + m.outputScroll += delta + if m.outputScroll < 0 { + m.outputScroll = 0 + } + if m.outputScroll > max { + m.outputScroll = max + } +} + +// outputViewport mirrors View/renderMain to give the OUTPUT transcript's inner (w, h), so the +// scroll handler clamps against the exact geometry the renderer windows to. +func (m Model) outputViewport() (w, h int) { + bottomH := inputH + if m.displayState() == StateApproval { + bottomH = m.approvalBandHeight() + } + mainH := m.height - statusH - footerH - bottomH + if mainH < 3 { + mainH = 3 + } + if m.narrow() { + outH := mainH * 55 / 100 + if outH < 3 { + outH = 3 + } + evH := mainH - outH + if evH < 3 { + evH = 3 + outH = mainH - evH + } + return m.width - 4, outH - 2 + } + leftW := m.width * 63 / 100 + return leftW - 4, mainH - 2 +} + +// outputViewportPage is the PgUp/PgDn step (a panel minus one row of overlap for context). +func (m Model) outputViewportPage() int { + _, h := m.outputViewport() + if h < 2 { + return 1 + } + return h - 1 +} + // transcriptNavUser moves the transcript selection to the previous (dir<0) or next // (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑ // grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow. @@ -1401,9 +1507,30 @@ func (m Model) currentDiff() string { return "" } +// openEventInspector opens the event inspector with a fresh (cleared) filter. +func (m *Model) openEventInspector() { + m.overlay = OverlayEventInspector + m.overlayEventIdx = 0 + m.eventFilter = "" + m.eventFilterTyping = false +} + +// currentEvents is the selected session's events, narrowed by the inspector filter (a +// case-insensitive substring of type/detail) when one is set. func (m Model) currentEvents() []EventEntry { - if s := m.session(m.selectedID); s != nil { + s := m.session(m.selectedID) + if s == nil { + return nil + } + if m.eventFilter == "" { return s.Events } - return nil + q := strings.ToLower(m.eventFilter) + out := make([]EventEntry, 0, len(s.Events)) + for _, e := range s.Events { + if strings.Contains(strings.ToLower(e.Type+" "+e.Detail), q) { + out = append(out, e) + } + } + return out } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 954886c5..0aae1cd8 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -493,10 +493,42 @@ func (m Model) welcomeRows(w int) []string { func (m Model) routerRows(w, h int) []string { t := m.theme - msgs := m.routerMessages[m.selectedID] - if len(msgs) == 0 { + if len(m.routerMessages[m.selectedID]) == 0 { return []string{t.span("awaiting router response…", t.P.Faint)} } + rows, msgStart := m.buildTranscriptRows(w) + // With a selection, scroll so the selected message is visible (top-aligned, clamped to the + // end); otherwise honour the free-scroll offset (0 = tail-follow the newest output). + if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h { + start := msgStart[m.transcriptSel] + if start > len(rows)-h { + start = len(rows) - h + } + if start < 0 { + start = 0 + } + return rows[start : start+h] + } + if len(rows) > h { + off := m.outputScroll + if max := len(rows) - h; off > max { + off = max + } + if off < 0 { + off = 0 + } + end := len(rows) - off + return rows[end-h : end] + } + return rows +} + +// buildTranscriptRows renders the selected session's transcript to display rows (and records each +// message's first row index for scroll-to-selection). Split out so the scroll handler can measure +// the full height against the same content the renderer windows. +func (m Model) buildTranscriptRows(w int) ([]string, []int) { + t := m.theme + msgs := m.routerMessages[m.selectedID] var rows []string msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection for mi, e := range msgs { @@ -552,22 +584,7 @@ func (m Model) routerRows(w, h int) []string { rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim)) } } - // With a selection, scroll so the selected message is visible (top-aligned, clamped - // to the end); otherwise tail-follow the newest output. - if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h { - start := msgStart[m.transcriptSel] - if start > len(rows)-h { - start = len(rows) - h - } - if start < 0 { - start = 0 - } - return rows[start : start+h] - } - if len(rows) > h { - rows = rows[len(rows)-h:] - } - return rows + return rows, msgStart } func (m Model) eventRows(w, h int) []string {