feat(tui-go): scroll tall modals instead of clipping them off-screen

The ? help and S stats modals rendered at fixed height; compositeOverlay
drops any rows past the screen, so on a 24-30 row terminal their bottom
sections — including the close hint — vanished with no way to reach them
(and the backdrop border corrupted). Recent help additions worsened it.

- Add a shared scrollable-modal renderer: helpBody()/statsBody() produce
  discrete lines, renderScrollModal() windows them by m.modalScroll (clamped
  to the viewport via modalBodyRows/scrollableModalMax) and pins the hint, so
  the box never exceeds the screen. A "(off-end/total)" indicator shows in the
  title when scrolled. Keys: ↑↓/jk line, PgUp/PgDn page, ^u/^d half, g/G ends;
  any other key still dismisses help. modal_scroll_test.go (2): clamp + no-op.
- Page the artifacts viewer (v) too: PgUp/PgDn were one line at a time — now a
  full body, with ^u/^d half and g/G ends via scrollArtifact().

Verified via cmd/preview: help/stats close hints now survive at h=20-28 (were
clipped below ~32/40) and show the scroll indicator; full at tall heights.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:26:21 +00:00
parent f043640373
commit 7114bb7658
4 changed files with 242 additions and 47 deletions
+55 -10
View File
@@ -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":