feat(tui-go): transcript free-scroll + help overlay + event filter
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user