diff --git a/src/components/BaseTextInput.tsx b/src/components/BaseTextInput.tsx index 94e702a2..cced4fdf 100644 --- a/src/components/BaseTextInput.tsx +++ b/src/components/BaseTextInput.tsx @@ -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 {showArgumentHint && {props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}}{children}; + return {showArgumentHint && {value.endsWith(" ") ? "" : " "}{props.argumentHint}}{children}; } const T0 = Box; const T1 = Text; const t4 = "truncate-end"; const t5 = showPlaceholder && props.placeholderElement ? props.placeholderElement : showPlaceholder && renderedPlaceholder ? {renderedPlaceholder} : {renderedValue}; - const t6 = showArgumentHint && {props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}; + const t6 = showArgumentHint && {value.endsWith(" ") ? "" : " "}{props.argumentHint}; let t7; if ($[4] !== T1 || $[5] !== children || $[6] !== props || $[7] !== t5 || $[8] !== t6) { t7 = {t5}{t6}{children}; diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 45c16233..4fea0001 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -252,14 +252,24 @@ function PromptInput({ show: false }); const [cursorOffset, setCursorOffset] = useState(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 diff --git a/src/components/TextInput.test.tsx b/src/components/TextInput.test.tsx new file mode 100644 index 00000000..ed6cac03 --- /dev/null +++ b/src/components/TextInput.test.tsx @@ -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 | null>(null) + const offsetTimerRef = React.useRef | null>(null) + + React.useEffect(() => { + return () => { + if (valueTimerRef.current) { + clearTimeout(valueTimerRef.current) + } + if (offsetTimerRef.current) { + clearTimeout(offsetTimerRef.current) + } + } + }, []) + + return ( + + { + 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 + /> + + ) +} + +function DelayedControlledVimTextInput(): React.ReactNode { + const [value, setValue] = React.useState('') + const [cursorOffset, setCursorOffset] = React.useState(0) + const valueTimerRef = React.useRef | null>(null) + const offsetTimerRef = React.useRef | null>(null) + + React.useEffect(() => { + return () => { + if (valueTimerRef.current) { + clearTimeout(valueTimerRef.current) + } + if (offsetTimerRef.current) { + clearTimeout(offsetTimerRef.current) + } + } + }, []) + + return ( + + { + 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 + /> + + ) +} + +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() + + 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() + + 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...') +}) diff --git a/src/hooks/useManagePlugins.ts b/src/hooks/useManagePlugins.ts index 7efe1d55..ca45e97f 100644 --- a/src/hooks/useManagePlugins.ts +++ b/src/hooks/useManagePlugins.ts @@ -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 : [] } diff --git a/src/hooks/useTextInput.ts b/src/hooks/useTextInput.ts index 90c4c4f8..1c9ab7eb 100644 --- a/src/hooks/useTextInput.ts +++ b/src/hooks/useTextInput.ts @@ -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, diff --git a/src/hooks/useVimInput.ts b/src/hooks/useVimInput.ts index 0aabc911..51d25b82 100644 --- a/src/hooks/useVimInput.ts +++ b/src/hooks/useVimInput.ts @@ -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) diff --git a/src/ink/components/App.tsx b/src/ink/components/App.tsx index 0404ddd9..e2846f25 100644 --- a/src/ink/components/App.tsx +++ b/src/ink/components/App.tsx @@ -115,7 +115,10 @@ export default class App extends PureComponent { keyParseState = INITIAL_STATE; // Timer for flushing incomplete escape sequences incompleteEscapeTimer: NodeJS.Timeout | null = null; - stdinMode: 'readable' | 'data' = process.env.OPENCLAUDE_USE_READABLE_STDIN === '1' ? 'readable' : 'data'; + // Default to readable-mode stdin (legacy Ink behavior). The data-mode path + // is kept as an explicit opt-in because some terminals can enter a state + // where startup input appears frozen when data mode is the default. + stdinMode: 'readable' | 'data' = process.env.OPENCLAUDE_USE_DATA_STDIN === '1' || process.env.OPENCLAUDE_USE_READABLE_STDIN === '0' ? 'data' : 'readable'; // Timeout durations for incomplete sequences (ms) readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations diff --git a/src/ink/ink.tsx b/src/ink/ink.tsx index 995fd69d..796fe74e 100644 --- a/src/ink/ink.tsx +++ b/src/ink/ink.tsx @@ -33,7 +33,7 @@ import createRenderer, { type Renderer } from './renderer.js'; import { CellWidth, CharPool, cellAt, createScreen, HyperlinkPool, isEmptyCellAt, migrateScreenPools, StylePool } from './screen.js'; import { applySearchHighlight } from './searchHighlight.js'; import { applySelectionOverlay, captureScrolledRows, clearSelection, createSelectionState, extendSelection, type FocusMove, findPlainTextUrlAt, getSelectedText, hasSelection, moveFocus, type SelectionState, selectLineAt, selectWordAt, shiftAnchor, shiftSelection, shiftSelectionForFollow, startSelection, updateSelection } from './selection.js'; -import { SYNC_OUTPUT_SUPPORTED, supportsExtendedKeys, type Terminal, writeDiffToTerminal } from './terminal.js'; +import { shouldSkipMainScreenSyncMarkers, shouldUseMainScreenRewrite, SYNC_OUTPUT_SUPPORTED, supportsExtendedKeys, type Terminal, writeDiffToTerminal } from './terminal.js'; import { CURSOR_HOME, cursorMove, cursorPosition, DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, ERASE_SCREEN } from './termio/csi.js'; import { DBP, DFE, DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN, SHOW_CURSOR } from './termio/dec.js'; import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, setClipboard, supportsTabStatus, wrapForMultiplexer } from './termio/osc.js'; @@ -609,12 +609,13 @@ export default class Ink { }; } const tDiff = performance.now(); + const rewriteMainScreen = !this.altScreenActive && shouldUseMainScreenRewrite(); const diff = this.log.render(prevFrame, frame, this.altScreenActive, // DECSTBM needs BSU/ESU atomicity — without it the outer terminal // renders the scrolled-but-not-yet-repainted intermediate state. // tmux is the main case (re-emits DECSTBM with its own timing and // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false). - SYNC_OUTPUT_SUPPORTED); + SYNC_OUTPUT_SUPPORTED, rewriteMainScreen); const diffMs = performance.now() - tDiff; // Swap buffers this.backFrame = this.frontFrame; @@ -759,7 +760,8 @@ export default class Ink { } } const tWrite = performance.now(); - writeDiffToTerminal(this.terminal, optimized, this.altScreenActive && !SYNC_OUTPUT_SUPPORTED); + const skipSyncMarkers = this.altScreenActive ? !SYNC_OUTPUT_SUPPORTED : rewriteMainScreen || shouldSkipMainScreenSyncMarkers(); + writeDiffToTerminal(this.terminal, optimized, skipSyncMarkers); const writeMs = performance.now() - tWrite; // Update blit safety for the NEXT frame. The frame just rendered diff --git a/src/ink/log-update.test.ts b/src/ink/log-update.test.ts new file mode 100644 index 00000000..39d43ce5 --- /dev/null +++ b/src/ink/log-update.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from 'bun:test' + +import type { Frame } from './frame.ts' +import { LogUpdate } from './log-update.ts' +import { + CellWidth, + CharPool, + createScreen, + HyperlinkPool, + setCellAt, + StylePool, +} from './screen.ts' + +function collectStdout(diff: ReturnType): string { + return diff + .filter((patch): patch is Extract<(typeof diff)[number], { type: 'stdout' }> => patch.type === 'stdout') + .map(patch => patch.content) + .join('') +} + +function createHarness() { + const stylePool = new StylePool() + const charPool = new CharPool() + const hyperlinkPool = new HyperlinkPool() + + return { + stylePool, + charPool, + hyperlinkPool, + log: new LogUpdate({ isTTY: true, stylePool }), + } +} + +function frameFromLines( + stylePool: StylePool, + charPool: CharPool, + hyperlinkPool: HyperlinkPool, + lines: string[], + cursor = { x: 0, y: lines.length, visible: true }, +): Frame { + const width = lines.reduce((max, line) => Math.max(max, line.length), 0) + const screen = createScreen(width, lines.length, stylePool, charPool, hyperlinkPool) + + for (const [y, line] of lines.entries()) { + for (const [x, char] of [...line].entries()) { + setCellAt(screen, x, y, { + char, + styleId: stylePool.none, + width: CellWidth.Narrow, + }) + } + } + + return { + screen, + viewport: { + width: Math.max(width, 1), + height: 10, + }, + cursor, + } +} + +test('ghostty main-screen rewrite paints prompt content without full terminal reset when width is stable', () => { + const { stylePool, charPool, hyperlinkPool, log } = createHarness() + const prev = frameFromLines(stylePool, charPool, hyperlinkPool, [' ']) + const next = frameFromLines(stylePool, charPool, hyperlinkPool, ['prompt']) + + const diff = log.render(prev, next, false, true, true) + const stdout = collectStdout(diff) + + expect(diff.some(patch => patch.type === 'clearTerminal')).toBe(false) + expect(diff.some(patch => patch.type === 'clear' && patch.count === 1)).toBe( + true, + ) + expect(stdout).toContain('prompt') +}) + +test('ghostty main-screen rewrite clears only the changed prompt tail before repainting', () => { + const { stylePool, charPool, hyperlinkPool, log } = createHarness() + const prev = frameFromLines( + stylePool, + charPool, + hyperlinkPool, + ['status', '> abc'], + ) + const next = frameFromLines( + stylePool, + charPool, + hyperlinkPool, + ['status', '> abcd'], + ) + + const diff = log.render(prev, next, false, true, true) + const stdout = collectStdout(diff) + + expect(diff.some(patch => patch.type === 'clearTerminal')).toBe(false) + expect(diff.some(patch => patch.type === 'clear' && patch.count === 1)).toBe( + true, + ) + expect(stdout).toContain('abcd') +}) + +test('ghostty main-screen rewrite falls back to incremental diff for larger changes', () => { + const { stylePool, charPool, hyperlinkPool, log } = createHarness() + const prev = frameFromLines( + stylePool, + charPool, + hyperlinkPool, + ['row 0', 'row 1', 'row 2', 'row 3', 'row 4', '> abc'], + ) + const next = frameFromLines( + stylePool, + charPool, + hyperlinkPool, + ['row 0 updated', 'row 1', 'row 2', 'row 3', 'row 4', '> abcd'], + ) + + const diff = log.render(prev, next, false, true, true) + const stdout = collectStdout(diff) + + expect(diff.some(patch => patch.type === 'clear')).toBe(false) + expect(stdout).toContain('updated') + expect(stdout).toContain('abcd') +}) diff --git a/src/ink/log-update.ts b/src/ink/log-update.ts index 4434b941..6ec6f893 100644 --- a/src/ink/log-update.ts +++ b/src/ink/log-update.ts @@ -125,6 +125,7 @@ export class LogUpdate { next: Frame, altScreen = false, decstbmSafe = true, + rewriteMainScreen = false, ): Diff { if (!this.options.isTTY) { return this.renderFullFrame(next) @@ -146,6 +147,13 @@ export class LogUpdate { return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool) } + if (!altScreen && rewriteMainScreen) { + const rewriteStartY = findMainScreenRewriteStart(prev.screen, next.screen) + if (rewriteStartY !== null) { + return rewriteMainScreenFrame(prev, next, stylePool, rewriteStartY) + } + } + // DECSTBM scroll optimization: when a ScrollBox's scrollTop changed, // shift content with a hardware scroll (CSI top;bot r + CSI n S/T) // instead of rewriting the whole scroll region. The shiftRows on @@ -420,34 +428,8 @@ export class LogUpdate { // Main screen: if cursor needs to be past the last line of content // (typical: cursor.y = screen.height), emit \n to create that line // since cursor movement can't create new lines. - if (altScreen) { - // no-op; next frame's CSI H anchors cursor - } else if (next.cursor.y >= next.screen.height) { - // Move to column 0 of current line, then emit newlines to reach target row - screen.txn(prev => { - const rowsToCreate = next.cursor.y - prev.y - if (rowsToCreate > 0) { - // Use CR to resolve pending wrap (if any) without advancing - // to the next line, then LF to create each new row. - const patches: Diff = new Array(1 + rowsToCreate) - patches[0] = CARRIAGE_RETURN - for (let i = 0; i < rowsToCreate; i++) { - patches[1 + i] = NEWLINE - } - return [patches, { dx: -prev.x, dy: rowsToCreate }] - } - // At or past target row - need to move cursor to correct position - const dy = next.cursor.y - prev.y - if (dy !== 0 || prev.x !== next.cursor.x) { - // Use CR to clear pending wrap (if any), then cursor move - const patches: Diff = [CARRIAGE_RETURN] - patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy }) - return [patches, { dx: next.cursor.x - prev.x, dy }] - } - return [[], { dx: 0, dy: 0 }] - }) - } else { - moveCursorTo(screen, next.cursor.x, next.cursor.y) + if (!altScreen) { + restoreMainScreenCursor(screen, next) } const elapsed = performance.now() - startTime @@ -467,6 +449,77 @@ export class LogUpdate { } } +function rewriteMainScreenFrame( + prev: Frame, + next: Frame, + stylePool: StylePool, + startY: number, +): Diff { + const diff: Diff = [] + const clearCount = prev.screen.height - startY + + if (clearCount > 0) { + const clearStartY = prev.screen.height - 1 + const clearCursor = new VirtualScreen(prev.cursor, next.viewport.width) + moveCursorTo(clearCursor, 0, clearStartY) + diff.push(...clearCursor.diff) + diff.push({ type: 'clear', count: clearCount }) + } + + const screen = new VirtualScreen( + clearCount > 0 ? { x: 0, y: startY } : prev.cursor, + next.viewport.width, + ) + renderFrameSlice(screen, next, startY, next.screen.height, stylePool) + restoreMainScreenCursor(screen, next) + + return [...diff, ...screen.diff] +} + +const MAX_MAIN_SCREEN_REWRITE_ROWS = 6 + +function findMainScreenRewriteStart(prev: Screen, next: Screen): number | null { + const commonHeight = Math.min(prev.height, next.height) + let firstChangedY = commonHeight + + for (let y = 0; y < commonHeight; y += 1) { + if (!rowsEqual(prev, next, y)) { + firstChangedY = y + break + } + } + + const rewriteRows = Math.max(prev.height, next.height) - firstChangedY + if (rewriteRows <= 0) { + return null + } + + return rewriteRows <= MAX_MAIN_SCREEN_REWRITE_ROWS ? firstChangedY : null +} + +function rowsEqual(prev: Screen, next: Screen, y: number): boolean { + if (prev.width !== next.width) { + return false + } + + if (prev.softWrap[y] !== next.softWrap[y]) { + return false + } + + const rowStart = y * prev.width + const rowEnd = rowStart + prev.width + for (let index = rowStart; index < rowEnd; index += 1) { + if ( + prev.cells64[index] !== next.cells64[index] || + prev.noSelect[index] !== next.noSelect[index] + ) { + return false + } + } + + return true +} + function transitionHyperlink( diff: Diff, current: Hyperlink, @@ -622,6 +675,37 @@ function renderFrameSlice( return screen } +function restoreMainScreenCursor(screen: VirtualScreen, next: Frame): void { + if (next.cursor.y >= next.screen.height) { + // Move to column 0 of current line, then emit newlines to reach target row + screen.txn(prev => { + const rowsToCreate = next.cursor.y - prev.y + if (rowsToCreate > 0) { + // Use CR to resolve pending wrap (if any) without advancing + // to the next line, then LF to create each new row. + const patches: Diff = new Array(1 + rowsToCreate) + patches[0] = CARRIAGE_RETURN + for (let i = 0; i < rowsToCreate; i++) { + patches[1 + i] = NEWLINE + } + return [patches, { dx: -prev.x, dy: rowsToCreate }] + } + // At or past target row - need to move cursor to correct position + const dy = next.cursor.y - prev.y + if (dy !== 0 || prev.x !== next.cursor.x) { + // Use CR to clear pending wrap (if any), then cursor move + const patches: Diff = [CARRIAGE_RETURN] + patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy }) + return [patches, { dx: next.cursor.x - prev.x, dy }] + } + return [[], { dx: 0, dy: 0 }] + }) + return + } + + moveCursorTo(screen, next.cursor.x, next.cursor.y) +} + type Delta = { dx: number; dy: number } /** diff --git a/src/ink/terminal.ts b/src/ink/terminal.ts index df68c80e..d3e6b1ef 100644 --- a/src/ink/terminal.ts +++ b/src/ink/terminal.ts @@ -135,6 +135,13 @@ export function setXtversionName(name: string): void { if (xtversionName === undefined) xtversionName = name } +export function isGhosttyTerminal(): boolean { + if (process.env.NODE_ENV === 'test') return false + if (process.env.TERM_PROGRAM === 'ghostty') return true + if (process.env.TERM === 'xterm-ghostty') return true + return xtversionName?.toLowerCase().startsWith('ghostty') ?? false +} + /** True if running in an xterm.js-based terminal (VS Code, Cursor, Windsurf * integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but * not forwarded over SSH) with the XTVERSION probe result (async, survives @@ -145,6 +152,20 @@ export function isXtermJs(): boolean { return xtversionName?.startsWith('xterm.js') ?? false } +/** Ghostty currently repaints main-screen prompt updates more reliably + * without DEC 2026 synchronized output. Prefer explicit terminal identity + * (TERM_PROGRAM/TERM or XTVERSION) in real sessions, but keep tests + * deterministic by disabling the env-based detection under NODE_ENV=test. */ +export function shouldSkipMainScreenSyncMarkers(): boolean { + return isGhosttyTerminal() +} + +/** Ghostty's main-screen prompt updates are currently more reliable when we + * bypass the incremental diff path and rewrite the visible prompt block. */ +export function shouldUseMainScreenRewrite(): boolean { + return isGhosttyTerminal() +} + // Terminals known to correctly implement the Kitty keyboard protocol // (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+ // disambiguation. We previously enabled unconditionally (#23350), assuming diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 227b2caa..bc5f1134 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -617,7 +617,6 @@ export function REPL({ const toolPermissionContext = useAppState(s => s.toolPermissionContext); const verbose = useAppState(s => s.verbose); const mcp = useAppState(s => s.mcp); - const plugins = useAppState(s => s.plugins); const agentDefinitions = useAppState(s => s.agentDefinitions); const fileHistory = useAppState(s => s.fileHistory); const initialMessage = useAppState(s => s.initialMessage); @@ -780,7 +779,7 @@ export function REPL({ }, [localTools, initialTools]); // Initialize plugin management - useManagePlugins({ + const pluginCommands = useManagePlugins({ enabled: !isRemoteSession }); const tasksV2 = useTasksV2WithCollapseEffect(); @@ -826,10 +825,16 @@ export function REPL({ }, [mainThreadAgentDefinition, mergedTools]); // Merge commands from local state, plugins, and MCP - const commandsWithPlugins = useMergedCommands(localCommands, plugins.commands as Command[]); + const commandsWithPlugins = useMergedCommands(localCommands, pluginCommands as Command[]); const mergedCommands = useMergedCommands(commandsWithPlugins, mcp.commands as Command[]); + // Keep plugin commands out of render-time command props. Feeding the full + // execution set into PromptInput/Messages reintroduced the startup repaint + // freeze, while transcript rendering still round-trips plugin skills via the + // SkillTool's `skill` payload without needing plugin command objects here. + const renderMergedCommands = useMergedCommands(localCommands, mcp.commands as Command[]); // Filter out all commands if disableSlashCommands is true const commands = useMemo(() => disableSlashCommands ? [] : mergedCommands, [disableSlashCommands, mergedCommands]); + const renderCommands = useMemo(() => disableSlashCommands ? [] : renderMergedCommands, [disableSlashCommands, renderMergedCommands]); useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients); useIdeSelection(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, setIDESelection); const [streamMode, setStreamMode] = useState('responding'); @@ -4427,7 +4432,7 @@ export function REPL({ // and transcript-mode are mutually exclusive (this early return), so // only one ScrollBox is ever mounted at a time. const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined; - const transcriptMessagesElement = ; + const transcriptMessagesElement = ; const transcriptToolJSX = toolJSX && {toolJSX.jsx} ; @@ -4595,7 +4600,7 @@ export function REPL({ jumpToNew(scrollRef.current); }} scrollable={<> - + {/* Hide the processing placeholder while a modal is showing — it would sit at the last visible transcript row right above @@ -4928,7 +4933,7 @@ export function REPL({ {"external" === 'ant' && skillImprovementSurvey.suggestion && } {showIssueFlagBanner && } { } - diff --git a/src/state/pluginCommandsStore.ts b/src/state/pluginCommandsStore.ts new file mode 100644 index 00000000..6310c9fc --- /dev/null +++ b/src/state/pluginCommandsStore.ts @@ -0,0 +1,13 @@ +import type { Command } from '../commands.js' +import { createStore } from './store.js' + +const pluginCommandsStore = createStore([]) + +export const getPluginCommandsState = (): Command[] => + pluginCommandsStore.getState() + +export const subscribePluginCommands = pluginCommandsStore.subscribe + +export function setPluginCommandsState(commands: Command[]): void { + pluginCommandsStore.setState(() => [...commands]) +} diff --git a/src/tools/SkillTool/SkillTool.test.ts b/src/tools/SkillTool/SkillTool.test.ts index dd09552a..bd23bb23 100644 --- a/src/tools/SkillTool/SkillTool.test.ts +++ b/src/tools/SkillTool/SkillTool.test.ts @@ -1,6 +1,29 @@ import { describe, expect, test } from 'bun:test' +import type { Command } from '../../commands.js' import { SkillTool } from './SkillTool.js' +import { renderToolUseMessage } from './UI.js' + +function createPromptCommand( + name: string, + options: { + source?: 'builtin' | 'plugin' | 'mcp' | 'bundled' + loadedFrom?: Command['loadedFrom'] + } = {}, +): Command { + return { + type: 'prompt', + name, + description: `${name} description`, + progressMessage: `${name} progress`, + contentLength: 0, + source: options.source ?? 'builtin', + loadedFrom: options.loadedFrom, + async getPromptForCommand() { + return [] + }, + } +} describe('SkillTool missing parameter handling', () => { test('missing skill stays required at the schema level', async () => { @@ -29,3 +52,47 @@ describe('SkillTool missing parameter handling', () => { expect(parsed.success).toBe(true) }) }) + +describe('SkillTool renderToolUseMessage', () => { + test('plugin skills render correctly without plugin command metadata', () => { + const pluginSkillName = 'plugin:review-pr' + + expect( + renderToolUseMessage( + { skill: pluginSkillName }, + { + commands: [], + }, + ), + ).toBe(pluginSkillName) + + expect( + renderToolUseMessage( + { skill: pluginSkillName }, + { + commands: [ + createPromptCommand(pluginSkillName, { + source: 'plugin', + loadedFrom: 'plugin', + }), + ], + }, + ), + ).toBe(pluginSkillName) + }) + + test('legacy commands still render with a slash prefix when metadata is present', () => { + expect( + renderToolUseMessage( + { skill: 'legacy-command' }, + { + commands: [ + createPromptCommand('legacy-command', { + loadedFrom: 'commands_DEPRECATED', + }), + ], + }, + ), + ).toBe('/legacy-command') + }) +}) diff --git a/src/tools/SkillTool/UI.tsx b/src/tools/SkillTool/UI.tsx index ed93a1a2..6c93bf09 100644 --- a/src/tools/SkillTool/UI.tsx +++ b/src/tools/SkillTool/UI.tsx @@ -54,7 +54,10 @@ export function renderToolUseMessage({ if (!skill) { return null; } - // Look up the command to check if it came from the legacy /commands folder + // Only legacy /commands_DEPRECATED entries need the command lookup so we can + // preserve the slash-prefixed display. Plugin skills already carry the + // invoked skill name in `skill`, so transcript/history rendering does not + // need plugin command metadata. const command = commands?.find(c => c.name === skill); const displayName = command?.loadedFrom === 'commands_DEPRECATED' ? `/${skill}` : skill; return displayName; diff --git a/src/types/textInputTypes.ts b/src/types/textInputTypes.ts index 1e77b56f..6137236a 100644 --- a/src/types/textInputTypes.ts +++ b/src/types/textInputTypes.ts @@ -226,8 +226,10 @@ export type VimMode = 'INSERT' | 'NORMAL' */ export type BaseInputState = { onInput: (input: string, key: Key) => void + value: string renderedValue: string offset: number + setValue: (value: string, offset?: number) => void setOffset: (offset: number) => void /** Cursor line (0-indexed) within the rendered text, accounting for wrapping. */ cursorLine: number diff --git a/src/utils/plugins/refresh.ts b/src/utils/plugins/refresh.ts index c31e8118..d8a9374e 100644 --- a/src/utils/plugins/refresh.ts +++ b/src/utils/plugins/refresh.ts @@ -21,6 +21,7 @@ import { getOriginalCwd } from '../../bootstrap/state.js' import type { Command } from '../../commands.js' import { reinitializeLspServerManager } from '../../services/lsp/manager.js' import type { AppState } from '../../state/AppState.js' +import { setPluginCommandsState } from '../../state/pluginCommandsStore.js' import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js' import { getAgentDefinitionsWithOverrides } from '../../tools/AgentTool/loadAgentsDir.js' import type { PluginError } from '../../types/plugin.js' @@ -92,6 +93,7 @@ export async function refreshActivePlugins( ]) const { enabled, disabled, errors } = pluginResult + setPluginCommandsState(pluginCommands) // Populate mcpServers/lspServers on each enabled plugin. These are lazy // cache slots NOT filled by loadAllPlugins() — they're written later by @@ -126,7 +128,7 @@ export async function refreshActivePlugins( ...prev.plugins, enabled, disabled, - commands: pluginCommands, + commands: [], errors: mergePluginErrors(prev.plugins.errors, errors), needsRefresh: false, },