Skip to content

Commit f75e834

Browse files
gingerhendrixclaude
andcommitted
feat(ask): show a scrollbar so overflowing content is visibly scrollable
The multiline `tooee ask` textarea (EditBufferRenderable) already scrolls its own viewport to follow the cursor (insert typing and cursor-mode motions) and responds to the mouse wheel, so overflowing content is reachable. But the editor has no scrollbar affordance, so a full box looks clipped/stuck and users can't tell there is more content above or below. Add a lightweight one-column EditorScrollbar that mirrors the editor's internal viewport (offsetY / total virtual lines) and only appears when content overflows. Wire it into both Ask (standalone `tooee ask`) and AskOverlay, keep the textarea's own viewport scrolling, and refresh the thumb on cursor change, content change, and wheel scroll. Add regression tests asserting overflow content is reachable (cursor-follow, gg, mouse wheel) and that the thumb appears on overflow and hides when content fits, for both Ask and AskOverlay. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b7d749a commit f75e834

4 files changed

Lines changed: 290 additions & 30 deletions

File tree

packages/ask/src/Ask.tsx

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "@tooee/commands"
2323
import type { ActionDefinition } from "@tooee/commands"
2424
import type { AskOptions } from "./types.js"
25+
import { EditorScrollbar } from "./EditorScrollbar.js"
2526
import {
2627
appendAtCursor,
2728
handleEditBufferVimMotion,
@@ -54,6 +55,10 @@ export function Ask({
5455
const inputRef = useRef<InputRenderable>(null)
5556
const didPositionInitialCursorRef = useRef(false)
5657
const vimMotionStateRef = useRef<VimMotionState>({ pendingG: false })
58+
// Bumped whenever the editor viewport may have moved (cursor, content, wheel)
59+
// so the scrollbar thumb re-computes from the editor's internal scroll state.
60+
const [scrollRevision, setScrollRevision] = useState(0)
61+
const bumpScroll = useCallback(() => setScrollRevision((r) => r + 1), [])
5762
const { invoke } = useCommandContext()
5863

5964
const { theme } = useTheme()
@@ -87,6 +92,11 @@ export function Ask({
8792
didPositionInitialCursorRef.current = true
8893
}, [defaultValue, multiline])
8994

95+
// Ensure the scrollbar computes once the editor ref and layout exist.
96+
useEffect(() => {
97+
bumpScroll()
98+
}, [bumpScroll])
99+
90100
const handleSubmit = () => {
91101
const text = multiline ? (textareaRef.current?.plainText ?? "") : value
92102
if (actions?.some((a) => a.id === "submit")) {
@@ -219,21 +229,30 @@ export function Ask({
219229
</text>
220230
)}
221231
{multiline ? (
222-
<textarea
223-
ref={textareaRef}
224-
focused={inputFocused}
225-
initialValue={defaultValue}
226-
placeholder={placeholder}
227-
textColor={theme.text}
228-
placeholderColor={theme.textMuted}
229-
cursorColor={cursorColor}
230-
cursorStyle={cursorStyle}
231-
backgroundColor="transparent"
232-
onSubmit={handleSubmit}
233-
onKeyDown={preventCursorModeEditorInput}
234-
onPaste={preventCursorModeEditorInput}
235-
style={{ flexGrow: 1 }}
236-
/>
232+
<box flexDirection="row" style={{ flexGrow: 1 }} onMouseScroll={bumpScroll}>
233+
<textarea
234+
ref={textareaRef}
235+
focused={inputFocused}
236+
initialValue={defaultValue}
237+
placeholder={placeholder}
238+
textColor={theme.text}
239+
placeholderColor={theme.textMuted}
240+
cursorColor={cursorColor}
241+
cursorStyle={cursorStyle}
242+
backgroundColor="transparent"
243+
onSubmit={handleSubmit}
244+
onKeyDown={preventCursorModeEditorInput}
245+
onPaste={preventCursorModeEditorInput}
246+
onCursorChange={bumpScroll}
247+
onContentChange={bumpScroll}
248+
style={{ flexGrow: 1 }}
249+
/>
250+
<EditorScrollbar
251+
target={textareaRef.current}
252+
revision={scrollRevision}
253+
color={theme.textMuted}
254+
/>
255+
</box>
237256
) : (
238257
<input
239258
ref={inputRef}

packages/ask/src/AskOverlay.tsx

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import { readPrimaryText } from "@tooee/clipboard"
1111
import { useTheme, CloseButton } from "@tooee/themes"
1212
import { useCommand, useMode, useSetMode } from "@tooee/commands"
13+
import { EditorScrollbar } from "./EditorScrollbar.js"
1314
import { appendAtCursor, openLineAtCursor, type VimMotionState } from "./vim-motions.js"
1415

1516
export interface AskOverlayProps {
@@ -36,6 +37,10 @@ export function AskOverlay({
3637
const inputRef = useRef<InputRenderable>(null)
3738
const didPositionInitialCursorRef = useRef(false)
3839
const vimMotionStateRef = useRef<VimMotionState>({ pendingG: false })
40+
// Bumped whenever the editor viewport may have moved (cursor, content, wheel)
41+
// so the scrollbar thumb re-computes from the editor's internal scroll state.
42+
const [scrollRevision, setScrollRevision] = useState(0)
43+
const bumpScroll = useCallback(() => setScrollRevision((r) => r + 1), [])
3944

4045
const inputFocused = mode === "insert" || mode === "cursor"
4146
const cursorStyle: CursorStyleOptions =
@@ -56,6 +61,11 @@ export function AskOverlay({
5661
didPositionInitialCursorRef.current = true
5762
}, [defaultValue, multiline])
5863

64+
// Ensure the scrollbar computes once the editor ref and layout exist.
65+
useEffect(() => {
66+
bumpScroll()
67+
}, [bumpScroll])
68+
5969
const handleSubmit = () => {
6070
const text = multiline ? (textareaRef.current?.plainText ?? "") : value
6171
onSubmit(text)
@@ -169,8 +179,9 @@ export function AskOverlay({
169179
const target = getMotionTarget()
170180
if (target) run(target as NonNullable<ReturnType<typeof getMotionTarget>>)
171181
vimMotionStateRef.current.pendingG = false
182+
bumpScroll()
172183
},
173-
[getMotionTarget],
184+
[getMotionTarget, bumpScroll],
174185
)
175186

176187
useCommand({
@@ -349,20 +360,29 @@ export function AskOverlay({
349360
{/* Input area */}
350361
<box flexDirection="column" style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}>
351362
{multiline ? (
352-
<textarea
353-
ref={textareaRef}
354-
focused={inputFocused}
355-
initialValue={defaultValue}
356-
textColor={theme.text}
357-
placeholderColor={theme.textMuted}
358-
cursorColor={cursorColor}
359-
cursorStyle={cursorStyle}
360-
backgroundColor="transparent"
361-
onSubmit={handleSubmit}
362-
onKeyDown={preventCursorModeEditorInput}
363-
onPaste={preventCursorModeEditorInput}
364-
style={{ flexGrow: 1 }}
365-
/>
363+
<box flexDirection="row" style={{ flexGrow: 1 }} onMouseScroll={bumpScroll}>
364+
<textarea
365+
ref={textareaRef}
366+
focused={inputFocused}
367+
initialValue={defaultValue}
368+
textColor={theme.text}
369+
placeholderColor={theme.textMuted}
370+
cursorColor={cursorColor}
371+
cursorStyle={cursorStyle}
372+
backgroundColor="transparent"
373+
onSubmit={handleSubmit}
374+
onKeyDown={preventCursorModeEditorInput}
375+
onPaste={preventCursorModeEditorInput}
376+
onCursorChange={bumpScroll}
377+
onContentChange={bumpScroll}
378+
style={{ flexGrow: 1 }}
379+
/>
380+
<EditorScrollbar
381+
target={textareaRef.current}
382+
revision={scrollRevision}
383+
color={theme.textMuted}
384+
/>
385+
</box>
366386
) : (
367387
<input
368388
ref={inputRef}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { TextareaRenderable, InputRenderable } from "@opentui/core"
2+
3+
export interface EditorScrollbarProps {
4+
/** The editor whose viewport this scrollbar reflects. */
5+
target: TextareaRenderable | InputRenderable | null
6+
/**
7+
* Bump this whenever the editor viewport may have changed (cursor move,
8+
* content change, wheel scroll) so the thumb position re-computes.
9+
*/
10+
revision: number
11+
color: string
12+
}
13+
14+
const THUMB_CHAR = "█"
15+
const TRACK_CHAR = "░"
16+
17+
/**
18+
* A one-column vertical scrollbar that mirrors an editor's internal viewport.
19+
*
20+
* The `<textarea>`/`<input>` (EditBufferRenderable) scrolls its own viewport to
21+
* follow the cursor and to respond to the mouse wheel, but has no built-in
22+
* scrollbar, so overflowing content is reachable yet not visibly indicated.
23+
* This renders a thumb reflecting `offsetY / totalLines` so the user can see
24+
* there is more content above/below the box.
25+
*
26+
* Renders nothing when content fits within the viewport (no overflow).
27+
*/
28+
export function EditorScrollbar({ target, color }: EditorScrollbarProps) {
29+
if (!target) return null
30+
31+
// Rendered height of the editor in rows (available post-layout).
32+
const height = target.height
33+
if (height <= 0) return null
34+
35+
// Total virtual (wrapped) line count, not the count currently in view.
36+
const total = target.editorView.getTotalVirtualLineCount()
37+
if (total <= height) return null // fits, no overflow -> no scrollbar
38+
39+
const offsetY = target.scrollY
40+
const maxOffset = Math.max(1, total - height)
41+
const clampedOffset = Math.min(Math.max(offsetY, 0), maxOffset)
42+
43+
const thumbSize = Math.max(1, Math.round((height / total) * height))
44+
const maxThumbTop = Math.max(0, height - thumbSize)
45+
const thumbTop = Math.round((clampedOffset / maxOffset) * maxThumbTop)
46+
47+
let content = ""
48+
for (let i = 0; i < height; i++) {
49+
const isThumb = i >= thumbTop && i < thumbTop + thumbSize
50+
content += isThumb ? THUMB_CHAR : TRACK_CHAR
51+
if (i < height - 1) content += "\n"
52+
}
53+
54+
return <text content={content} fg={color} selectable={false} />
55+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { act } from "react"
3+
import { TooeeProvider } from "@tooee/shell"
4+
import { testRender } from "../../../test/support/test-render.ts"
5+
import { Ask } from "../src/Ask.js"
6+
import { AskOverlay } from "../src/AskOverlay.js"
7+
8+
let testSetup: Awaited<ReturnType<typeof testRender>>
9+
10+
afterEach(() => {
11+
testSetup?.renderer.destroy()
12+
})
13+
14+
const THUMB = "█"
15+
const TRACK = "░"
16+
17+
// 30 uniquely-identifiable lines, far taller than the boxes used below.
18+
const tallValue = Array.from({ length: 30 }, (_, i) => `L${String(i + 1).padStart(2, "0")}`).join(
19+
"\n",
20+
)
21+
22+
async function setupAsk(value: string, width = 40, height = 14) {
23+
const s = await testRender(
24+
<TooeeProvider initialMode="insert">
25+
<Ask prompt="Q" multiline defaultValue={value} />
26+
</TooeeProvider>,
27+
{ width, height, kittyKeyboard: true },
28+
)
29+
await s.renderOnce()
30+
return s
31+
}
32+
33+
async function setupOverlay(value: string, width = 60, height = 20) {
34+
const s = await testRender(
35+
<TooeeProvider initialMode="insert">
36+
<AskOverlay
37+
prompt="Q"
38+
multiline
39+
defaultValue={value}
40+
onSubmit={() => {}}
41+
onCancel={() => {}}
42+
/>
43+
</TooeeProvider>,
44+
{ width, height, kittyKeyboard: true },
45+
)
46+
await s.renderOnce()
47+
return s
48+
}
49+
50+
async function press(key: string, modifiers?: { ctrl?: boolean; shift?: boolean }) {
51+
await act(async () => {
52+
testSetup.mockInput.pressKey(key, modifiers)
53+
})
54+
await testSetup.renderOnce()
55+
}
56+
57+
async function pressEscape() {
58+
await act(async () => {
59+
testSetup.mockInput.pressEscape()
60+
})
61+
await testSetup.renderOnce()
62+
}
63+
64+
async function wheel(x: number, y: number, direction: "up" | "down") {
65+
await act(async () => {
66+
await testSetup.mockMouse.scroll(x, y, direction)
67+
})
68+
await testSetup.renderOnce()
69+
}
70+
71+
describe("Ask overflow scrolling", () => {
72+
test("the tail of overflowing content is reachable (cursor follows to the bottom)", async () => {
73+
testSetup = await setupAsk(tallValue)
74+
const frame = testSetup.captureCharFrame()
75+
// The box is far smaller than 30 lines; the last line must still be visible.
76+
expect(frame).toContain("L30")
77+
expect(frame).not.toContain("L01")
78+
})
79+
80+
test("the head of overflowing content is reachable via cursor-mode motions (gg)", async () => {
81+
testSetup = await setupAsk(tallValue)
82+
await pressEscape()
83+
await press("g")
84+
await press("g")
85+
const frame = testSetup.captureCharFrame()
86+
expect(frame).toContain("L01")
87+
expect(frame).not.toContain("L30")
88+
})
89+
90+
test("the mouse wheel scrolls overflowing content", async () => {
91+
testSetup = await setupAsk(tallValue)
92+
await pressEscape()
93+
await press("g")
94+
await press("g")
95+
expect(testSetup.captureCharFrame()).toContain("L01")
96+
97+
// Wheel down over the editor should reveal content further down.
98+
await wheel(10, 6, "down")
99+
const frame = testSetup.captureCharFrame()
100+
expect(frame).not.toContain("L01")
101+
})
102+
103+
test("a scrollbar thumb is shown when content overflows", async () => {
104+
testSetup = await setupAsk(tallValue)
105+
const frame = testSetup.captureCharFrame()
106+
expect(frame).toContain(THUMB)
107+
expect(frame).toContain(TRACK)
108+
})
109+
110+
test("no scrollbar is shown when content fits", async () => {
111+
testSetup = await setupAsk("one\ntwo\nthree", 40, 20)
112+
const frame = testSetup.captureCharFrame()
113+
expect(frame).not.toContain(THUMB)
114+
expect(frame).not.toContain(TRACK)
115+
})
116+
117+
test("the scrollbar thumb moves as the viewport scrolls", async () => {
118+
testSetup = await setupAsk(tallValue)
119+
120+
// Cursor starts at the end -> thumb sits at the bottom of the track.
121+
const bottomThumbRow = thumbRow(testSetup.captureCharFrame())
122+
123+
await pressEscape()
124+
await press("g")
125+
await press("g")
126+
const topThumbRow = thumbRow(testSetup.captureCharFrame())
127+
128+
expect(topThumbRow).toBeLessThan(bottomThumbRow)
129+
})
130+
})
131+
132+
describe("AskOverlay overflow scrolling", () => {
133+
test("the tail of overflowing content is reachable", async () => {
134+
testSetup = await setupOverlay(tallValue)
135+
const frame = testSetup.captureCharFrame()
136+
expect(frame).toContain("L30")
137+
expect(frame).not.toContain("L01")
138+
})
139+
140+
test("the head is reachable via gg and a scrollbar thumb is shown", async () => {
141+
testSetup = await setupOverlay(tallValue)
142+
expect(testSetup.captureCharFrame()).toContain(THUMB)
143+
144+
await pressEscape()
145+
await press("g")
146+
await press("g")
147+
const frame = testSetup.captureCharFrame()
148+
expect(frame).toContain("L01")
149+
expect(frame).not.toContain("L30")
150+
})
151+
152+
test("no scrollbar when content fits", async () => {
153+
testSetup = await setupOverlay("one\ntwo\nthree")
154+
const frame = testSetup.captureCharFrame()
155+
expect(frame).not.toContain(THUMB)
156+
expect(frame).not.toContain(TRACK)
157+
})
158+
})
159+
160+
/** Index of the first frame row that contains a thumb character. */
161+
function thumbRow(frame: string): number {
162+
const rows = frame.split("\n")
163+
const idx = rows.findIndex((row) => row.includes(THUMB))
164+
if (idx === -1) throw new Error("no thumb found in frame")
165+
return idx
166+
}

0 commit comments

Comments
 (0)