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:
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
231
src/components/TextInput.test.tsx
Normal file
231
src/components/TextInput.test.tsx
Normal 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...')
|
||||
})
|
||||
Reference in New Issue
Block a user