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