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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user