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:
@@ -1,3 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { isInputModeCharacter } from 'src/components/PromptInput/inputModes.js'
|
||||
import { useNotifications } from 'src/context/notifications.js'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
@@ -100,9 +101,74 @@ export function useTextInput({
|
||||
prewarmModifiers()
|
||||
}
|
||||
|
||||
const offset = externalOffset
|
||||
const setOffset = onOffsetChange
|
||||
const cursor = Cursor.fromText(originalValue, columns, offset)
|
||||
// Keep a local text/cursor mirror so consecutive keystrokes can advance
|
||||
// immediately even if the controlled parent value hasn't committed yet.
|
||||
const [renderState, setRenderState] = useState(() => ({
|
||||
value: originalValue,
|
||||
offset: externalOffset,
|
||||
}))
|
||||
const liveValueRef = useRef(originalValue)
|
||||
const liveOffsetRef = useRef(externalOffset)
|
||||
const lastSeenPropsRef = useRef({
|
||||
value: originalValue,
|
||||
offset: externalOffset,
|
||||
})
|
||||
const updateRenderedInput = (nextValue: string, nextOffset: number): void => {
|
||||
liveValueRef.current = nextValue
|
||||
liveOffsetRef.current = nextOffset
|
||||
setRenderState(prev =>
|
||||
prev.value === nextValue && prev.offset === nextOffset
|
||||
? prev
|
||||
: { value: nextValue, offset: nextOffset },
|
||||
)
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
lastSeenPropsRef.current.value === originalValue &&
|
||||
lastSeenPropsRef.current.offset === externalOffset
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastSeenPropsRef.current = {
|
||||
value: originalValue,
|
||||
offset: externalOffset,
|
||||
}
|
||||
updateRenderedInput(originalValue, externalOffset)
|
||||
}, [originalValue, externalOffset])
|
||||
|
||||
const value = renderState.value
|
||||
const offset = renderState.offset
|
||||
const getLiveValue = (): string => liveValueRef.current
|
||||
const getLiveCursor = (): Cursor =>
|
||||
Cursor.fromText(liveValueRef.current, columns, liveOffsetRef.current)
|
||||
const setValue = (nextValue: string, nextOffset = liveOffsetRef.current): void => {
|
||||
const previousValue = liveValueRef.current
|
||||
const previousOffset = liveOffsetRef.current
|
||||
|
||||
if (previousValue === nextValue && previousOffset === nextOffset) {
|
||||
return
|
||||
}
|
||||
|
||||
updateRenderedInput(nextValue, nextOffset)
|
||||
|
||||
if (previousValue !== nextValue) {
|
||||
onChange(nextValue)
|
||||
}
|
||||
|
||||
if (previousOffset !== nextOffset) {
|
||||
onOffsetChange(nextOffset)
|
||||
}
|
||||
}
|
||||
const setOffset = (nextOffset: number): void => {
|
||||
if (nextOffset === liveOffsetRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
updateRenderedInput(liveValueRef.current, nextOffset)
|
||||
onOffsetChange(nextOffset)
|
||||
}
|
||||
const cursor = Cursor.fromText(value, columns, offset)
|
||||
const { addNotification, removeNotification } = useNotifications()
|
||||
|
||||
const handleCtrlC = useDoublePress(
|
||||
@@ -111,9 +177,11 @@ export function useTextInput({
|
||||
},
|
||||
() => onExit?.(),
|
||||
() => {
|
||||
if (originalValue) {
|
||||
const currentValue = getLiveValue()
|
||||
if (currentValue) {
|
||||
updateRenderedInput('', 0)
|
||||
onChange('')
|
||||
setOffset(0)
|
||||
onOffsetChange(0)
|
||||
onHistoryReset?.()
|
||||
}
|
||||
},
|
||||
@@ -125,7 +193,8 @@ export function useTextInput({
|
||||
// not dialog dismissal, and needs the double-press safety mechanism.
|
||||
const handleEscape = useDoublePress(
|
||||
(show: boolean) => {
|
||||
if (!originalValue || !show) {
|
||||
const currentValue = getLiveValue()
|
||||
if (!currentValue || !show) {
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
@@ -136,17 +205,19 @@ export function useTextInput({
|
||||
})
|
||||
},
|
||||
() => {
|
||||
const currentValue = getLiveValue()
|
||||
// Remove the "Esc again to clear" notification immediately
|
||||
removeNotification('escape-again-to-clear')
|
||||
onClearInput?.()
|
||||
if (originalValue) {
|
||||
if (currentValue) {
|
||||
// Track double-escape usage for feature discovery
|
||||
// Save to history before clearing
|
||||
if (originalValue.trim() !== '') {
|
||||
addToHistory(originalValue)
|
||||
if (currentValue.trim() !== '') {
|
||||
addToHistory(currentValue)
|
||||
}
|
||||
updateRenderedInput('', 0)
|
||||
onChange('')
|
||||
setOffset(0)
|
||||
onOffsetChange(0)
|
||||
onHistoryReset?.()
|
||||
}
|
||||
},
|
||||
@@ -154,13 +225,13 @@ export function useTextInput({
|
||||
|
||||
const handleEmptyCtrlD = useDoublePress(
|
||||
show => {
|
||||
if (originalValue !== '') {
|
||||
if (getLiveValue() !== '') {
|
||||
return
|
||||
}
|
||||
onExitMessage?.(show, 'Ctrl-D')
|
||||
},
|
||||
() => {
|
||||
if (originalValue !== '') {
|
||||
if (getLiveValue() !== '') {
|
||||
return
|
||||
}
|
||||
onExit?.()
|
||||
@@ -168,6 +239,7 @@ export function useTextInput({
|
||||
)
|
||||
|
||||
function handleCtrlD(): MaybeCursor {
|
||||
const cursor = getLiveCursor()
|
||||
if (cursor.text === '') {
|
||||
// When input is empty, handle double-press
|
||||
handleEmptyCtrlD()
|
||||
@@ -178,24 +250,28 @@ export function useTextInput({
|
||||
}
|
||||
|
||||
function killToLineEnd(): Cursor {
|
||||
const cursor = getLiveCursor()
|
||||
const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
|
||||
pushToKillRing(killed, 'append')
|
||||
return newCursor
|
||||
}
|
||||
|
||||
function killToLineStart(): Cursor {
|
||||
const cursor = getLiveCursor()
|
||||
const { cursor: newCursor, killed } = cursor.deleteToLineStart()
|
||||
pushToKillRing(killed, 'prepend')
|
||||
return newCursor
|
||||
}
|
||||
|
||||
function killWordBefore(): Cursor {
|
||||
const cursor = getLiveCursor()
|
||||
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
|
||||
pushToKillRing(killed, 'prepend')
|
||||
return newCursor
|
||||
}
|
||||
|
||||
function yank(): Cursor {
|
||||
const cursor = getLiveCursor()
|
||||
const text = getLastKill()
|
||||
if (text.length > 0) {
|
||||
const startOffset = cursor.offset
|
||||
@@ -207,6 +283,7 @@ export function useTextInput({
|
||||
}
|
||||
|
||||
function handleYankPop(): Cursor {
|
||||
const cursor = getLiveCursor()
|
||||
const popResult = yankPop()
|
||||
if (!popResult) {
|
||||
return cursor
|
||||
@@ -222,13 +299,16 @@ export function useTextInput({
|
||||
}
|
||||
|
||||
const handleCtrl = mapInput([
|
||||
['a', () => cursor.startOfLine()],
|
||||
['b', () => cursor.left()],
|
||||
['a', () => getLiveCursor().startOfLine()],
|
||||
['b', () => getLiveCursor().left()],
|
||||
['c', handleCtrlC],
|
||||
['d', handleCtrlD],
|
||||
['e', () => cursor.endOfLine()],
|
||||
['f', () => cursor.right()],
|
||||
['h', () => cursor.deleteTokenBefore() ?? cursor.backspace()],
|
||||
['e', () => getLiveCursor().endOfLine()],
|
||||
['f', () => getLiveCursor().right()],
|
||||
['h', () => {
|
||||
const cursor = getLiveCursor()
|
||||
return cursor.deleteTokenBefore() ?? cursor.backspace()
|
||||
}],
|
||||
['k', killToLineEnd],
|
||||
['n', () => downOrHistoryDown()],
|
||||
['p', () => upOrHistoryUp()],
|
||||
@@ -238,13 +318,15 @@ export function useTextInput({
|
||||
])
|
||||
|
||||
const handleMeta = mapInput([
|
||||
['b', () => cursor.prevWord()],
|
||||
['f', () => cursor.nextWord()],
|
||||
['d', () => cursor.deleteWordAfter()],
|
||||
['b', () => getLiveCursor().prevWord()],
|
||||
['f', () => getLiveCursor().nextWord()],
|
||||
['d', () => getLiveCursor().deleteWordAfter()],
|
||||
['y', handleYankPop],
|
||||
])
|
||||
|
||||
function handleEnter(key: Key) {
|
||||
const cursor = getLiveCursor()
|
||||
const currentValue = getLiveValue()
|
||||
if (
|
||||
multiline &&
|
||||
cursor.offset > 0 &&
|
||||
@@ -263,10 +345,11 @@ export function useTextInput({
|
||||
if (env.terminal === 'Apple_Terminal' && isModifierPressed('shift')) {
|
||||
return cursor.insert('\n')
|
||||
}
|
||||
onSubmit?.(originalValue)
|
||||
onSubmit?.(currentValue)
|
||||
}
|
||||
|
||||
function upOrHistoryUp() {
|
||||
const cursor = getLiveCursor()
|
||||
if (disableCursorMovementForUpDownKeys) {
|
||||
onHistoryUp?.()
|
||||
return cursor
|
||||
@@ -291,6 +374,7 @@ export function useTextInput({
|
||||
return cursor
|
||||
}
|
||||
function downOrHistoryDown() {
|
||||
const cursor = getLiveCursor()
|
||||
if (disableCursorMovementForUpDownKeys) {
|
||||
onHistoryDown?.()
|
||||
return cursor
|
||||
@@ -315,7 +399,7 @@ export function useTextInput({
|
||||
return cursor
|
||||
}
|
||||
|
||||
function mapKey(key: Key): InputMapper {
|
||||
function mapKey(key: Key, cursor: Cursor): InputMapper {
|
||||
switch (true) {
|
||||
case key.escape:
|
||||
return () => {
|
||||
@@ -429,6 +513,7 @@ export function useTextInput({
|
||||
}
|
||||
|
||||
function onInput(input: string, key: Key): void {
|
||||
const currentCursor = getLiveCursor()
|
||||
// Note: Image paste shortcut (chat:imagePaste) is handled via useKeybindings in PromptInput
|
||||
|
||||
// Apply filter if provided
|
||||
@@ -446,18 +531,15 @@ export function useTextInput({
|
||||
|
||||
// Apply all DEL characters as backspace operations synchronously
|
||||
// Try to delete tokens first, fall back to character backspace
|
||||
let currentCursor = cursor
|
||||
let nextCursor = currentCursor
|
||||
for (let i = 0; i < delCount; i++) {
|
||||
currentCursor =
|
||||
currentCursor.deleteTokenBefore() ?? currentCursor.backspace()
|
||||
nextCursor =
|
||||
nextCursor.deleteTokenBefore() ?? nextCursor.backspace()
|
||||
}
|
||||
|
||||
// Update state once with the final result
|
||||
if (!cursor.equals(currentCursor)) {
|
||||
if (cursor.text !== currentCursor.text) {
|
||||
onChange(currentCursor.text)
|
||||
}
|
||||
setOffset(currentCursor.offset)
|
||||
if (!currentCursor.equals(nextCursor)) {
|
||||
setValue(nextCursor.text, nextCursor.offset)
|
||||
}
|
||||
resetKillAccumulation()
|
||||
resetYankState()
|
||||
@@ -474,13 +556,10 @@ export function useTextInput({
|
||||
resetYankState()
|
||||
}
|
||||
|
||||
const nextCursor = mapKey(key)(filteredInput)
|
||||
const nextCursor = mapKey(key, currentCursor)(filteredInput)
|
||||
if (nextCursor) {
|
||||
if (!cursor.equals(nextCursor)) {
|
||||
if (cursor.text !== nextCursor.text) {
|
||||
onChange(nextCursor.text)
|
||||
}
|
||||
setOffset(nextCursor.offset)
|
||||
if (!currentCursor.equals(nextCursor)) {
|
||||
setValue(nextCursor.text, nextCursor.offset)
|
||||
}
|
||||
// SSH-coalesced Enter: on slow links, "o" + Enter can arrive as one
|
||||
// chunk "o\r". parseKeypress only matches s === '\r', so it hit the
|
||||
@@ -512,6 +591,7 @@ export function useTextInput({
|
||||
|
||||
return {
|
||||
onInput,
|
||||
value,
|
||||
renderedValue: cursor.render(
|
||||
cursorChar,
|
||||
mask,
|
||||
@@ -520,6 +600,7 @@ export function useTextInput({
|
||||
maxVisibleLines,
|
||||
),
|
||||
offset,
|
||||
setValue,
|
||||
setOffset,
|
||||
cursorLine: cursorPos.line - cursor.getViewportStartLine(maxVisibleLines),
|
||||
cursorColumn: cursorPos.column,
|
||||
|
||||
Reference in New Issue
Block a user