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:
@@ -184,6 +184,27 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
s.LastEventAt = nowMillis() - 3000
|
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":
|
case "grants":
|
||||||
m.connected = true
|
m.connected = true
|
||||||
m.currentModel = "llama-cpp:default"
|
m.currentModel = "llama-cpp:default"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const (
|
|||||||
OverlayStatusbar
|
OverlayStatusbar
|
||||||
OverlayGrants
|
OverlayGrants
|
||||||
OverlayGrantScope
|
OverlayGrantScope
|
||||||
|
OverlayHelp
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// 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.
|
// the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json.
|
||||||
actionsHidden bool
|
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.
|
// standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply.
|
||||||
grants []protocol.GrantDto
|
grants []protocol.GrantDto
|
||||||
grantsLoading bool
|
grantsLoading bool
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,8 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.grantsModal())
|
return m.center(m.grantsModal())
|
||||||
case OverlayGrantScope:
|
case OverlayGrantScope:
|
||||||
return m.center(m.grantScopeModal())
|
return m.center(m.grantScopeModal())
|
||||||
|
case OverlayHelp:
|
||||||
|
return m.center(m.helpModal())
|
||||||
}
|
}
|
||||||
return base
|
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(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint))
|
||||||
}
|
}
|
||||||
b.WriteString("\n\n")
|
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 {
|
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()))
|
return m.center(t.Overlay.Width(w).Render(b.String()))
|
||||||
}
|
}
|
||||||
// Budget the plain detail so the styled row never needs ANSI-aware truncation
|
// 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)
|
mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim)
|
||||||
b.WriteString(row + "\n")
|
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())
|
modal := t.Overlay.Width(w).Render(b.String())
|
||||||
return m.center(modal)
|
return m.center(modal)
|
||||||
@@ -688,6 +707,52 @@ func (m Model) grantScopeModal() string {
|
|||||||
return m.center(modal)
|
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 {
|
func (m Model) toolPaletteModal() string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
w := m.modalWidth()
|
w := m.modalWidth()
|
||||||
|
|||||||
@@ -231,14 +231,30 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
return m.normalEnter()
|
return m.normalEnter()
|
||||||
case tea.KeyEsc:
|
case tea.KeyEsc:
|
||||||
// In-session, esc first drops a transcript selection (back to tail-follow);
|
// In-session, esc first drops a transcript selection or output scroll (back to
|
||||||
// otherwise it clears the session-list filter.
|
// tail-follow); otherwise it clears the session-list filter.
|
||||||
if m.transcriptSel >= 0 {
|
if m.transcriptSel >= 0 {
|
||||||
m.transcriptSel = -1
|
m.transcriptSel = -1
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if m.outputScroll != 0 {
|
||||||
|
m.outputScroll = 0
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.filter = ""
|
m.filter = ""
|
||||||
return m, nil
|
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:
|
case tea.KeyCtrlX:
|
||||||
if m.currentDiff() != "" {
|
if m.currentDiff() != "" {
|
||||||
m.overlay = OverlayDiff
|
m.overlay = OverlayDiff
|
||||||
@@ -252,6 +268,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch string(k.Runes) {
|
switch string(k.Runes) {
|
||||||
case "i":
|
case "i":
|
||||||
m.enterInsert(ModeRouter)
|
m.enterInsert(ModeRouter)
|
||||||
|
case "?":
|
||||||
|
m.overlay = OverlayHelp
|
||||||
case "/":
|
case "/":
|
||||||
m.enterInsert(ModeFilter)
|
m.enterInsert(ModeFilter)
|
||||||
case "j":
|
case "j":
|
||||||
@@ -261,8 +279,7 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "p":
|
case "p":
|
||||||
m.openPalette()
|
m.openPalette()
|
||||||
case "e":
|
case "e":
|
||||||
m.overlay = OverlayEventInspector
|
m.openEventInspector()
|
||||||
m.overlayEventIdx = 0
|
|
||||||
case "t":
|
case "t":
|
||||||
m.overlay = OverlayToolPalette
|
m.overlay = OverlayToolPalette
|
||||||
case "v":
|
case "v":
|
||||||
@@ -600,6 +617,12 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleSessionsKey(k)
|
return m.handleSessionsKey(k)
|
||||||
}
|
}
|
||||||
if k.Type == tea.KeyEsc {
|
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
|
m.overlay = OverlayNone
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -621,7 +644,27 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case OverlayEventInspector:
|
case OverlayEventInspector:
|
||||||
evs := m.currentEvents()
|
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 {
|
switch {
|
||||||
|
case runeIs(k, "/"):
|
||||||
|
m.eventFilterTyping = true
|
||||||
|
case runeIs(k, "c"):
|
||||||
|
m.eventFilter = ""
|
||||||
|
m.overlayEventIdx = 0
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||||
if m.overlayEventIdx > 0 {
|
if m.overlayEventIdx > 0 {
|
||||||
m.overlayEventIdx--
|
m.overlayEventIdx--
|
||||||
@@ -631,6 +674,9 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.overlayEventIdx++
|
m.overlayEventIdx++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case OverlayHelp:
|
||||||
|
// Any key dismisses the cheat-sheet (esc handled above).
|
||||||
|
m.overlay = OverlayNone
|
||||||
case OverlayToolPalette:
|
case OverlayToolPalette:
|
||||||
if runeIs(k, "t") {
|
if runeIs(k, "t") {
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
@@ -973,6 +1019,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"},
|
||||||
{"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"},
|
||||||
{"mode", "s", "toggle mode", "switch chat / steering"},
|
{"mode", "s", "toggle mode", "switch chat / steering"},
|
||||||
{"cancel", "c", "cancel session", "stop the selected session"},
|
{"cancel", "c", "cancel session", "stop the selected session"},
|
||||||
{"back", "l", "back to list", "leave the current 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":
|
case "models":
|
||||||
m.openModelsOverlay()
|
m.openModelsOverlay()
|
||||||
case "events":
|
case "events":
|
||||||
m.overlay = OverlayEventInspector
|
m.openEventInspector()
|
||||||
m.overlayEventIdx = 0
|
|
||||||
case "artifacts":
|
case "artifacts":
|
||||||
m.openArtifacts()
|
m.openArtifacts()
|
||||||
case "stats":
|
case "stats":
|
||||||
@@ -1022,6 +1068,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
|||||||
m.sbIndex = 0
|
m.sbIndex = 0
|
||||||
case "actions":
|
case "actions":
|
||||||
m.toggleInlineActions()
|
m.toggleInlineActions()
|
||||||
|
case "help":
|
||||||
|
m.overlay = OverlayHelp
|
||||||
case "mode":
|
case "mode":
|
||||||
m.cycleChatMode()
|
m.cycleChatMode()
|
||||||
case "cancel":
|
case "cancel":
|
||||||
@@ -1310,6 +1358,64 @@ func (m *Model) toggleInlineActions() {
|
|||||||
saveInlineActionsHidden(m.actionsHidden)
|
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
|
// 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+↑
|
// (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.
|
// 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 ""
|
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 {
|
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 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -493,10 +493,42 @@ func (m Model) welcomeRows(w int) []string {
|
|||||||
|
|
||||||
func (m Model) routerRows(w, h int) []string {
|
func (m Model) routerRows(w, h int) []string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
msgs := m.routerMessages[m.selectedID]
|
if len(m.routerMessages[m.selectedID]) == 0 {
|
||||||
if len(msgs) == 0 {
|
|
||||||
return []string{t.span("awaiting router response…", t.P.Faint)}
|
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
|
var rows []string
|
||||||
msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
|
msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
|
||||||
for mi, e := range msgs {
|
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))
|
rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// With a selection, scroll so the selected message is visible (top-aligned, clamped
|
return rows, msgStart
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) eventRows(w, h int) []string {
|
func (m Model) eventRows(w, h int) []string {
|
||||||
|
|||||||
Reference in New Issue
Block a user