fix(tui): pager supports scrolling (up/down/page/top/bottom)

The pager overlay backing /history, /toolsets, /help and any paged slash
output only advanced with Enter/Space and closed at the end. Could not
scroll back, scroll line-by-line, or jump to endpoints.

Adds Up/Down (↑↓, j/k), PgUp (b), g/G for top/bottom, keeps existing
Enter/Space/PgDn forward-and-auto-close, and clamps offset so
over-scrolling past the last page is a no-op.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 10:43:55 -05:00
parent ce98e1ef11
commit 887dfc4067
22 changed files with 258 additions and 82 deletions

View file

@ -28,8 +28,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu
return (
<Text color={color}>
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}
{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
</Text>
)
}
@ -127,7 +126,11 @@ export function StatusRule({
<Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze} wrap="truncate-end">
{'─ '}
{busy ? <FaceTicker color={statusColor} startedAt={turnStartedAt} /> : <Text color={statusColor}>{status}</Text>}
{busy ? (
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
) : (
<Text color={statusColor}>{status}</Text>
)}
<Text color={t.color.dim}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? (

View file

@ -157,8 +157,8 @@ export function FloatingOverlays({
<Box marginTop={1}>
<Text color={ui.theme.color.dim}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `Enter/Space for more · q to close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · q to close (${overlay.pager.lines.length} lines)`}
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · q close (${overlay.pager.lines.length} lines)`}
</Text>
</Box>
</Box>

View file

@ -1,11 +1,11 @@
import { Box, Text, useInput } from '@hermes/ink'
import { useState } from 'react'
import { isMac } from '../lib/platform.js'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
import { TextInput } from './textInput.js'
import { isMac } from '../lib/platform.js'
const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
@ -130,7 +130,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
</Box>
<Text color={t.color.dim}>
Enter send · Esc {choices.length ? 'back' : 'cancel'} · {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '}
{isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
</Text>
</Box>
)

View file

@ -277,8 +277,9 @@ function useFwdDelete(active: boolean) {
type PasteResult = { cursor: number; value: string } | null
const isPasteResultPromise = (value: PasteResult | Promise<PasteResult> | null | undefined): value is Promise<PasteResult> =>
!!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
const isPasteResultPromise = (
value: PasteResult | Promise<PasteResult> | null | undefined
): value is Promise<PasteResult> => !!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
export function TextInput({
columns = 80,
@ -522,9 +523,11 @@ export function TextInput({
}
const range = selRange()
const nextValue = range
? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end)
: vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current)
const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length
commit(nextValue, nextCursor)
@ -778,7 +781,9 @@ interface TextInputProps {
focus?: boolean
mask?: string
onChange: (v: string) => void
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
onPaste?: (
e: PasteEvent
) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
onSubmit?: (v: string) => void
placeholder?: string
value: string