diff --git a/apps/tui-go/internal/app/modal_scroll_test.go b/apps/tui-go/internal/app/modal_scroll_test.go new file mode 100644 index 00000000..51a85370 --- /dev/null +++ b/apps/tui-go/internal/app/modal_scroll_test.go @@ -0,0 +1,39 @@ +package app + +import "testing" + +// scrollModal must clamp the body offset of a tall fixed-content modal (help / stats) to +// [0, scrollableModalMax] so paging past either end lands exactly — the modal scrolls +// instead of clipping its bottom (incl. the close hint) off-screen. +func TestModalScrollClampsToBounds(t *testing.T) { + m := inSessionModel(120, 24) // short enough that the help cheat-sheet overflows + m.overlay = OverlayHelp + + body := m.activeModalBody() + max := m.scrollableModalMax(len(body)) + if max <= 0 { + t.Fatalf("help body (%d lines) must exceed the modal viewport (%d) to test scrolling", len(body), m.modalBodyRows()) + } + + m.scrollModal(10_000) // past the bottom + if m.modalScroll != max { + t.Fatalf("scroll past end clamped to %d, want max %d", m.modalScroll, max) + } + m.scrollModal(-10_000) // past the top + if m.modalScroll != 0 { + t.Fatalf("scroll past top clamped to %d, want 0", m.modalScroll) + } +} + +// A modal that fits the screen never scrolls. +func TestModalScrollNoOpWhenFits(t *testing.T) { + m := inSessionModel(120, 60) // tall enough that help fits in full + m.overlay = OverlayHelp + if max := m.scrollableModalMax(len(m.activeModalBody())); max != 0 { + t.Fatalf("help should fit at height 60, got max scroll %d", max) + } + m.scrollModal(5) + if m.modalScroll != 0 { + t.Fatalf("a modal that fits should not scroll, got %d", m.modalScroll) + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 37d240e4..2289bef7 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -271,6 +271,7 @@ type Model struct { overlay OverlayKind overlayEventIdx int diffScrollOffset int + modalScroll int // body scroll for tall fixed-content modals (help, stats) eventStripShown bool // artifact viewer (OverlayArtifacts) — populated by the artifact.list response diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 41d3a214..503c616b 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -722,23 +722,103 @@ func (m Model) grantScopeModal() string { } // helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it. -func (m Model) helpModal() string { +// modalBodyRows is how many body lines a scrollable fixed-content modal shows: the +// screen height minus the modal chrome (border 2 + padding 2 + title block 2 + hint +// block 2). Sized so a modal that fits shows in full and a taller one scrolls rather +// than clipping its bottom off-screen (the old compositeOverlay behaviour). +func (m Model) modalBodyRows() int { + if r := m.height - 8; r > 3 { + return r + } + return 3 +} + +// scrollableModalMax is the largest scroll offset for a body of n lines, anchoring the +// last page to the bottom. +func (m Model) scrollableModalMax(n int) int { + if max := n - m.modalBodyRows(); max > 0 { + return max + } + return 0 +} + +// activeModalBody returns the body lines of whichever scrollable fixed-content modal is +// open, so the key handler can clamp m.modalScroll against the same content it renders. +func (m Model) activeModalBody() []string { + switch m.overlay { + case OverlayHelp: + return m.helpBody() + case OverlayStats: + if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { + return nil + } + return m.statsBody() + } + return nil +} + +// scrollModal moves the active fixed-content modal's body offset by delta lines, clamped +// to [0, scrollableModalMax] — same contract as the diff/output scrollers. +func (m *Model) scrollModal(delta int) { + max := m.scrollableModalMax(len(m.activeModalBody())) + off := m.modalScroll + delta + if off > max { + off = max + } + if off < 0 { + off = 0 + } + m.modalScroll = off +} + +// renderScrollModal lays out a title + scrollable body + pinned hint inside the modal box, +// windowing body by m.modalScroll so the box never exceeds the screen. A position indicator +// appears in the title row whenever the body is taller than the viewport. +func (m Model) renderScrollModal(title, titleSuffix string, body []string, hint [][2]string) string { t := m.theme w := m.modalWidth() + rows := m.modalBodyRows() + off := m.modalScroll + if mx := m.scrollableModalMax(len(body)); off > mx { + off = mx + } + if off < 0 { + off = 0 + } + end := off + rows + if end > len(body) { + end = len(body) + } + var b strings.Builder + b.WriteString(m.titleLine(title)) + if titleSuffix != "" { + b.WriteString(mbg(t, titleSuffix, t.P.Faint)) + } + if len(body) > rows { + b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(end)+"/"+itoa(len(body))+")", t.P.Faint)) + } + b.WriteString("\n\n") + for i := off; i < end; i++ { + b.WriteString(body[i] + "\n") + } + b.WriteString("\n" + modalHints(t, hint)) + return t.Overlay.Width(w).Render(b.String()) +} +// helpBody is the keybinding cheat-sheet as discrete lines (so renderScrollModal can window it). +func (m Model) helpBody() []string { + t := m.theme 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") + var out []string + section := func(title string, rows []kb) { + out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title)) 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") + out = append(out, " "+key+mbg(t, r.desc, t.P.Fg)) } - b.WriteString("\n") + out = append(out, "") } - - var b strings.Builder - b.WriteString(m.titleLine("keybindings") + "\n\n") - section(&b, "session", []kb{ + section("session", []kb{ {"i", "compose a message"}, {"^↑ / ^↓", "jump to your previous / next message"}, {"y", "copy the selected message (OSC52)"}, @@ -747,7 +827,7 @@ func (m Model) helpModal() string { {"^x", "open the latest diff"}, {"l", "back to the session list"}, }) - section(&b, "overlays", []kb{ + section("overlays", []kb{ {"p", "command palette (shows all shortcuts)"}, {"e", "event inspector (/ to filter)"}, {"t / v", "tools / artifacts"}, @@ -755,7 +835,7 @@ func (m Model) helpModal() string { {"m / g", "swap model / edit config"}, {"G / I", "grants / idea board"}, }) - section(&b, "approval band", []kb{ + section("approval band", []kb{ {"y / a / enter", "approve once (T3+ asks again to confirm)"}, {"n / r", "reject"}, {"e / s", "steer (add a note)"}, @@ -763,15 +843,18 @@ func (m Model) helpModal() string { {"↑ / ↓", "walk the pending queue"}, {"^x", "fullscreen the diff / command preview"}, }) - section(&b, "diff / preview viewer (^x)", []kb{ + section("diff / preview viewer (^x)", []kb{ {"↑ / ↓", "scroll a line"}, {"PgUp / PgDn", "scroll a page (^u / ^d half)"}, {"g / G", "jump to top / end"}, {"^x / esc", "close"}, }) - b.WriteString(modalHints(t, [][2]string{{"any key", "close"}})) - modal := t.Overlay.Width(w).Render(b.String()) - return m.center(modal) + return out +} + +func (m Model) helpModal() string { + return m.renderScrollModal("keybindings", "", m.helpBody(), + [][2]string{{"↑↓", "scroll"}, {"esc", "close"}}) } func (m Model) toolPaletteModal() string { @@ -883,6 +966,20 @@ func (m Model) artifactContentMaxScroll(w int) int { return max } +// scrollArtifact moves the selected artifact's content offset by delta lines, clamped to +// the scrollable range (same contract as the diff/output scrollers). +func (m *Model) scrollArtifact(delta int) { + max := m.artifactContentMaxScroll(m.modalWidth() - 6) + off := m.artifactScroll + delta + if off > max { + off = max + } + if off < 0 { + off = 0 + } + m.artifactScroll = off +} + func (m Model) artifactsModal() string { t := m.theme w := m.modalWidth() @@ -961,7 +1058,7 @@ func (m Model) artifactsModal() string { b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"v/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } @@ -993,14 +1090,14 @@ func (m Model) statsModal() string { t := m.theme w := m.modalWidth() - var b strings.Builder - b.WriteString(m.titleLine("session stats")) - if m.selectedID != "" { - b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint)) - } - b.WriteString("\n\n") - + // Loading / empty: a small fixed modal, no scroll needed. if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { + var b strings.Builder + b.WriteString(m.titleLine("session stats")) + if m.selectedID != "" { + b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint)) + } + b.WriteString("\n\n") msg := "loading…" if !m.statsLoading && m.stats == nil { msg = "no stats for this session" @@ -1010,15 +1107,29 @@ func (m Model) statsModal() string { return t.Overlay.Width(w).Render(b.String()) } + suffix := "" + if m.selectedID != "" { + suffix = " (" + shortID(m.selectedID) + ")" + } + return m.renderScrollModal("session stats", suffix, m.statsBody(), + [][2]string{{"↑↓", "scroll"}, {"S/esc", "close"}}) +} + +// statsBody is the session-stats report as discrete lines (so renderScrollModal can window it). +// Callers must ensure m.stats is loaded for the selected session. +func (m Model) statsBody() []string { + t := m.theme s := m.stats - cw := w - 4 // modal inner content width (borders + padding) + cw := m.modalWidth() - 4 // modal inner content width (borders + padding) + var out []string section := func(label string) string { return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label) } // put clips each data line to the inner width so it never wraps on a slim modal. - put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") } + put := func(styled string) { out = append(out, clip(styled, cw)) } line := func(s string) string { return mbg(t, s, t.P.Fg) } faint := func(s string) string { return mbg(t, s, t.P.Faint) } + blank := func() { out = append(out, "") } // nameW shrinks the breakdown name column on slim terminals. nameW := 20 if cw < 52 { @@ -1028,10 +1139,10 @@ func (m Model) statsModal() string { // header row: duration + event count put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) + faint(" events ") + line(itoa(int(s.EventCount)))) - b.WriteString("\n") + blank() // inference - b.WriteString(section("Inference") + "\n") + out = append(out, section("Inference")) put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s", s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond))) for i, p := range s.PerProvider { @@ -1042,10 +1153,10 @@ func (m Model) statsModal() string { put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s", nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond))) } - b.WriteString("\n") + blank() // tools - b.WriteString(section("Tools") + "\n") + out = append(out, section("Tools")) put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs))) for i, tl := range s.PerTool { if i >= statsBreakdownRows { @@ -1054,35 +1165,34 @@ func (m Model) statsModal() string { } put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs))) } - b.WriteString("\n") + blank() // approvals - b.WriteString(section("Approvals") + "\n") + out = append(out, section("Approvals")) put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms", s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs))) for _, tr := range s.PerTier { put(faint(fmt.Sprintf(" %-3s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs))) } - b.WriteString("\n") + blank() // failures f := s.Failures - b.WriteString(section("Failures") + "\n") + out = append(out, section("Failures")) put(line(fmt.Sprintf(" inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d", f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures))) - b.WriteString("\n") + blank() // time accounting other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct if other < 0 { other = 0 } - b.WriteString(section("Time") + "\n") + out = append(out, section("Time")) put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%", s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other))) - b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}})) - return t.Overlay.Width(w).Render(b.String()) + return out } // shortID trims a long artifact id for the list column while staying identifiable. diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 14946733..04da8531 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -270,6 +270,7 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.enterInsert(ModeRouter) case "?": m.overlay = OverlayHelp + m.modalScroll = 0 case "/": m.enterInsert(ModeFilter) case "j": @@ -687,8 +688,29 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } } case OverlayHelp: - // Any key dismisses the cheat-sheet (esc handled above). - m.overlay = OverlayNone + // The cheat-sheet scrolls when it's taller than the screen; any non-scroll key + // dismisses it (esc handled above), keeping the quick-glance feel. + page := m.modalBodyRows() + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + m.scrollModal(-1) + case k.Type == tea.KeyDown || runeIs(k, "j"): + m.scrollModal(1) + case k.Type == tea.KeyPgUp: + m.scrollModal(-page) + case k.Type == tea.KeyPgDown: + m.scrollModal(page) + case k.Type == tea.KeyCtrlU: + m.scrollModal(-page / 2) + case k.Type == tea.KeyCtrlD: + m.scrollModal(page / 2) + case runeIs(k, "g"): + m.modalScroll = 0 + case runeIs(k, "G"): + m.modalScroll = m.scrollableModalMax(len(m.activeModalBody())) + default: + m.overlay = OverlayNone + } case OverlayToolPalette: if runeIs(k, "t") { m.overlay = OverlayNone @@ -706,14 +728,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.artifactScroll = 0 } case k.Type == tea.KeyPgUp: - if m.artifactScroll > 0 { - m.artifactScroll-- - } + m.scrollArtifact(-m.artifactContentBodyH()) case k.Type == tea.KeyPgDown: - contentW := m.modalWidth() - 6 - if m.artifactScroll < m.artifactContentMaxScroll(contentW) { - m.artifactScroll++ - } + m.scrollArtifact(m.artifactContentBodyH()) + case k.Type == tea.KeyCtrlU: + m.scrollArtifact(-m.artifactContentBodyH() / 2) + case k.Type == tea.KeyCtrlD: + m.scrollArtifact(m.artifactContentBodyH() / 2) + case runeIs(k, "g"): + m.artifactScroll = 0 + case runeIs(k, "G"): + m.artifactScroll = m.artifactContentMaxScroll(m.modalWidth() - 6) case runeIs(k, "v"): m.overlay = OverlayNone } @@ -739,7 +764,25 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.overlay = OverlayNone } case OverlayStats: - if runeIs(k, "S") { + page := m.modalBodyRows() + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + m.scrollModal(-1) + case k.Type == tea.KeyDown || runeIs(k, "j"): + m.scrollModal(1) + case k.Type == tea.KeyPgUp: + m.scrollModal(-page) + case k.Type == tea.KeyPgDown: + m.scrollModal(page) + case k.Type == tea.KeyCtrlU: + m.scrollModal(-page / 2) + case k.Type == tea.KeyCtrlD: + m.scrollModal(page / 2) + case runeIs(k, "g"): + m.modalScroll = 0 + case runeIs(k, "G"): + m.modalScroll = m.scrollableModalMax(len(m.activeModalBody())) + case runeIs(k, "S"): m.overlay = OverlayNone } case OverlayIdeas: @@ -962,6 +1005,7 @@ func (m *Model) openStats() { return } m.overlay = OverlayStats + m.modalScroll = 0 // Reuse a cached report only if it's for this session; otherwise show a loading state. if m.statsFor != m.selectedID { m.stats = nil @@ -1082,6 +1126,7 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.toggleInlineActions() case "help": m.overlay = OverlayHelp + m.modalScroll = 0 case "mode": m.cycleChatMode() case "cancel":