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

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useSyncExternalStore } from 'react'
import type { Command } from '../commands.js'
import { useNotifications } from '../context/notifications.js'
import {
@@ -7,6 +7,11 @@ import {
} from '../services/analytics/index.js'
import { reinitializeLspServerManager } from '../services/lsp/manager.js'
import { useAppState, useSetAppState } from '../state/AppState.js'
import {
getPluginCommandsState,
setPluginCommandsState,
subscribePluginCommands,
} from '../state/pluginCommandsStore.js'
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
import { count } from '../utils/array.js'
import { logForDebugging } from '../utils/debug.js'
@@ -39,6 +44,11 @@ export function useManagePlugins({
}: {
enabled?: boolean
} = {}) {
const pluginCommands = useSyncExternalStore(
subscribePluginCommands,
getPluginCommandsState,
getPluginCommandsState,
)
const setAppState = useSetAppState()
const needsRefresh = useAppState(s => s.plugins.needsRefresh)
const { addNotification } = useNotifications()
@@ -74,6 +84,7 @@ export function useManagePlugins({
try {
commands = await getPluginCommands()
setPluginCommandsState(commands)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
@@ -82,6 +93,7 @@ export function useManagePlugins({
source: 'plugin-commands',
error: `Failed to load plugin commands: ${errorMessage}`,
})
setPluginCommandsState([])
}
try {
@@ -173,7 +185,7 @@ export function useManagePlugins({
...prevState.plugins,
enabled,
disabled,
commands,
commands: [],
errors: mergedErrors,
},
}
@@ -226,6 +238,7 @@ export function useManagePlugins({
logError(errorObj)
logForDebugging(`Error loading plugins: ${error}`)
// Set empty state on error, but preserve LSP errors and add the new error
setPluginCommandsState([])
setAppState(prevState => {
// Keep existing LSP/non-plugin-loading errors
const existingLspErrors = prevState.plugins.errors.filter(
@@ -284,6 +297,11 @@ export function useManagePlugins({
})
}, [initialPluginLoad, enabled])
useEffect(() => {
if (enabled) return
setPluginCommandsState([])
}, [enabled])
// Plugin state changed on disk (background reconcile, /plugin menu,
// external settings edit). Show a notification; user runs /reload-plugins
// to apply. The previous auto-refresh here had a stale-cache bug (only
@@ -301,4 +319,6 @@ export function useManagePlugins({
// Do NOT auto-refresh. Do NOT reset needsRefresh — /reload-plugins
// consumes it via refreshActivePlugins().
}, [enabled, needsRefresh, addNotification])
return enabled ? pluginCommands : []
}

View File

@@ -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,

View File

@@ -70,14 +70,14 @@ export function useVimInput(props: UseVimInputProps): VimInputState {
// Vim behavior: move cursor left by 1 when exiting insert mode
// (unless at beginning of line or at offset 0)
const offset = textInput.offset
if (offset > 0 && props.value[offset - 1] !== '\n') {
if (offset > 0 && textInput.value[offset - 1] !== '\n') {
textInput.setOffset(offset - 1)
}
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
setMode('NORMAL')
onModeChange?.('NORMAL')
}, [onModeChange, textInput, props.value])
}, [onModeChange, textInput])
function createOperatorContext(
cursor: Cursor,
@@ -85,8 +85,8 @@ export function useVimInput(props: UseVimInputProps): VimInputState {
): OperatorContext {
return {
cursor,
text: props.value,
setText: (newText: string) => props.onChange(newText),
text: textInput.value,
setText: (newText: string) => textInput.setValue(newText),
setOffset: (offset: number) => textInput.setOffset(offset),
enterInsert: (offset: number) => switchToInsertMode(offset),
getRegister: () => persistentRef.current.register,
@@ -110,15 +110,18 @@ export function useVimInput(props: UseVimInputProps): VimInputState {
const change = persistentRef.current.lastChange
if (!change) return
const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
const cursor = Cursor.fromText(
textInput.value,
props.columns,
textInput.offset,
)
const ctx = createOperatorContext(cursor, true)
switch (change.type) {
case 'insert':
if (change.text) {
const newCursor = cursor.insert(change.text)
props.onChange(newCursor.text)
textInput.setOffset(newCursor.offset)
textInput.setValue(newCursor.text, newCursor.offset)
}
break
@@ -179,7 +182,11 @@ export function useVimInput(props: UseVimInputProps): VimInputState {
// lookups expect single chars and a prepended space would break them.
const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput
const input = state.mode === 'INSERT' ? filtered : rawInput
const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
const cursor = Cursor.fromText(
textInput.value,
props.columns,
textInput.offset,
)
if (key.ctrl) {
textInput.onInput(input, key)