fix(tui): restore prompt rendering on startup (#498)

* fix(tui): restore prompt rendering on startup

* test(tui): document render-time command split

* fix(tui): reduce ghostty prompt repaint scope
This commit is contained in:
sooth
2026-04-09 08:40:06 -04:00
committed by GitHub
parent c328fdf9e2
commit e30ad17ae0
17 changed files with 778 additions and 100 deletions

View File

@@ -31,9 +31,11 @@ export function BaseTextInput(t0) {
} = t0;
const {
onInput,
value,
renderedValue,
cursorLine,
cursorColumn
cursorColumn,
offset,
} = inputState;
const t1 = Boolean(props.focus && props.showCursor && terminalFocus);
let t2;
@@ -78,7 +80,7 @@ export function BaseTextInput(t0) {
renderedPlaceholder
} = renderPlaceholder({
placeholder: props.placeholder,
value: props.value,
value,
showCursor: props.showCursor,
focus: props.focus,
terminalFocus,
@@ -88,9 +90,9 @@ export function BaseTextInput(t0) {
useInput(wrappedOnInput, {
isActive: props.focus
});
const commandWithoutArgs = props.value && props.value.trim().indexOf(" ") === -1 || props.value && props.value.endsWith(" ");
const showArgumentHint = Boolean(props.argumentHint && props.value && commandWithoutArgs && props.value.startsWith("/"));
const cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || props.cursorOffset < h.start || props.cursorOffset >= h.end) : props.highlights;
const commandWithoutArgs = value && value.trim().indexOf(" ") === -1 || value && value.endsWith(" ");
const showArgumentHint = Boolean(props.argumentHint && value && commandWithoutArgs && value.startsWith("/"));
const cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || offset < h.start || offset >= h.end) : props.highlights;
const {
viewportCharOffset,
viewportCharEnd
@@ -102,13 +104,13 @@ export function BaseTextInput(t0) {
})) : cursorFiltered;
const hasHighlights = filteredHighlights && filteredHighlights.length > 0;
if (hasHighlights) {
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{value.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
}
const T0 = Box;
const T1 = Text;
const t4 = "truncate-end";
const t5 = showPlaceholder && props.placeholderElement ? props.placeholderElement : showPlaceholder && renderedPlaceholder ? <Ansi>{renderedPlaceholder}</Ansi> : <Ansi>{renderedValue}</Ansi>;
const t6 = showArgumentHint && <Text dimColor={true}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
const t6 = showArgumentHint && <Text dimColor={true}>{value.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
let t7;
if ($[4] !== T1 || $[5] !== children || $[6] !== props || $[7] !== t5 || $[8] !== t6) {
t7 = <T1 wrap={t4} dimColor={props.dimColor}>{t5}{t6}{children}</T1>;

View File

@@ -252,14 +252,24 @@ function PromptInput({
show: false
});
const [cursorOffset, setCursorOffset] = useState<number>(input.length);
// Track the last input value set via internal handlers so we can detect
// external input changes (e.g. speech-to-text injection) and move cursor to end.
// Track the last input value set via internal handlers so external updates
// (for example speech-to-text injection) can still move the cursor to end
// without clobbering a pending internal keystroke during render.
const lastInternalInputRef = React.useRef(input);
if (input !== lastInternalInputRef.current) {
// Input changed externally (not through any internal handler) — move cursor to end
setCursorOffset(input.length);
const lastPropInputRef = React.useRef(input);
React.useLayoutEffect(() => {
if (input === lastPropInputRef.current) {
return;
}
lastPropInputRef.current = input;
if (input === lastInternalInputRef.current) {
return;
}
lastInternalInputRef.current = input;
}
setCursorOffset(prev => prev === input.length ? prev : input.length);
}, [input]);
// Wrap onInputChange to track internal changes before they trigger re-render
const trackAndSetInput = React.useCallback((value: string) => {
lastInternalInputRef.current = value;
@@ -2201,7 +2211,7 @@ function PromptInput({
multiline: true,
onSubmit,
onChange,
value: historyMatch ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input,
value: isSearchingHistory && historyMatch ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input,
// History navigation is handled via TextInput props (onHistoryUp/onHistoryDown),
// NOT via useKeybindings. This allows useTextInput's upOrHistoryUp/downOrHistoryDown
// to try cursor movement first and only fall through to history navigation when the

View File

@@ -0,0 +1,231 @@
import { PassThrough } from 'node:stream'
import { expect, test } from 'bun:test'
import React from 'react'
import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js'
import TextInput from './TextInput.js'
import VimTextInput from './VimTextInput.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) {
break
}
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) {
break
}
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return {
stdout,
stdin,
getOutput: () => output,
}
}
function DelayedControlledTextInput(): React.ReactNode {
const [value, setValue] = React.useState('')
const [cursorOffset, setCursorOffset] = React.useState(0)
const valueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const offsetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
return () => {
if (valueTimerRef.current) {
clearTimeout(valueTimerRef.current)
}
if (offsetTimerRef.current) {
clearTimeout(offsetTimerRef.current)
}
}
}, [])
return (
<AppStateProvider>
<TextInput
value={value}
onChange={nextValue => {
if (valueTimerRef.current) {
clearTimeout(valueTimerRef.current)
}
valueTimerRef.current = setTimeout(() => {
setValue(nextValue)
}, 200)
}}
onSubmit={() => {}}
placeholder="Type here..."
columns={60}
cursorOffset={cursorOffset}
onChangeCursorOffset={nextOffset => {
if (offsetTimerRef.current) {
clearTimeout(offsetTimerRef.current)
}
offsetTimerRef.current = setTimeout(() => {
setCursorOffset(nextOffset)
}, 200)
}}
focus
showCursor
multiline
/>
</AppStateProvider>
)
}
function DelayedControlledVimTextInput(): React.ReactNode {
const [value, setValue] = React.useState('')
const [cursorOffset, setCursorOffset] = React.useState(0)
const valueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const offsetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
return () => {
if (valueTimerRef.current) {
clearTimeout(valueTimerRef.current)
}
if (offsetTimerRef.current) {
clearTimeout(offsetTimerRef.current)
}
}
}, [])
return (
<AppStateProvider>
<VimTextInput
value={value}
onChange={nextValue => {
if (valueTimerRef.current) {
clearTimeout(valueTimerRef.current)
}
valueTimerRef.current = setTimeout(() => {
setValue(nextValue)
}, 200)
}}
onSubmit={() => {}}
placeholder="Type here..."
columns={60}
cursorOffset={cursorOffset}
onChangeCursorOffset={nextOffset => {
if (offsetTimerRef.current) {
clearTimeout(offsetTimerRef.current)
}
offsetTimerRef.current = setTimeout(() => {
setCursorOffset(nextOffset)
}, 200)
}}
initialMode="INSERT"
focus
showCursor
multiline
/>
</AppStateProvider>
)
}
test('TextInput renders typed characters before delayed parent value commits', async () => {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<DelayedControlledTextInput />)
await Bun.sleep(50)
stdin.write('a')
await Bun.sleep(25)
stdin.write('b')
await Bun.sleep(25)
const output = stripAnsi(extractLastFrame(getOutput()))
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(25)
expect(output).toContain('ab')
expect(output).not.toContain('Type here...')
})
test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<DelayedControlledVimTextInput />)
await Bun.sleep(50)
stdin.write('a')
await Bun.sleep(25)
stdin.write('s')
await Bun.sleep(25)
stdin.write('d')
await Bun.sleep(25)
stdin.write('f')
await Bun.sleep(25)
const output = stripAnsi(extractLastFrame(getOutput()))
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(25)
expect(output).toContain('asdf')
expect(output).not.toContain('Type here...')
})