fix(tui): restore prompt rendering on startup (#498)
* fix(tui): restore prompt rendering on startup * test(tui): document render-time command split * fix(tui): reduce ghostty prompt repaint scope
This commit is contained in:
@@ -31,9 +31,11 @@ export function BaseTextInput(t0) {
|
|||||||
} = t0;
|
} = t0;
|
||||||
const {
|
const {
|
||||||
onInput,
|
onInput,
|
||||||
|
value,
|
||||||
renderedValue,
|
renderedValue,
|
||||||
cursorLine,
|
cursorLine,
|
||||||
cursorColumn
|
cursorColumn,
|
||||||
|
offset,
|
||||||
} = inputState;
|
} = inputState;
|
||||||
const t1 = Boolean(props.focus && props.showCursor && terminalFocus);
|
const t1 = Boolean(props.focus && props.showCursor && terminalFocus);
|
||||||
let t2;
|
let t2;
|
||||||
@@ -78,7 +80,7 @@ export function BaseTextInput(t0) {
|
|||||||
renderedPlaceholder
|
renderedPlaceholder
|
||||||
} = renderPlaceholder({
|
} = renderPlaceholder({
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
value: props.value,
|
value,
|
||||||
showCursor: props.showCursor,
|
showCursor: props.showCursor,
|
||||||
focus: props.focus,
|
focus: props.focus,
|
||||||
terminalFocus,
|
terminalFocus,
|
||||||
@@ -88,9 +90,9 @@ export function BaseTextInput(t0) {
|
|||||||
useInput(wrappedOnInput, {
|
useInput(wrappedOnInput, {
|
||||||
isActive: props.focus
|
isActive: props.focus
|
||||||
});
|
});
|
||||||
const commandWithoutArgs = props.value && props.value.trim().indexOf(" ") === -1 || props.value && props.value.endsWith(" ");
|
const commandWithoutArgs = value && value.trim().indexOf(" ") === -1 || value && value.endsWith(" ");
|
||||||
const showArgumentHint = Boolean(props.argumentHint && props.value && commandWithoutArgs && props.value.startsWith("/"));
|
const showArgumentHint = Boolean(props.argumentHint && value && commandWithoutArgs && 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 cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || offset < h.start || offset >= h.end) : props.highlights;
|
||||||
const {
|
const {
|
||||||
viewportCharOffset,
|
viewportCharOffset,
|
||||||
viewportCharEnd
|
viewportCharEnd
|
||||||
@@ -102,13 +104,13 @@ export function BaseTextInput(t0) {
|
|||||||
})) : cursorFiltered;
|
})) : cursorFiltered;
|
||||||
const hasHighlights = filteredHighlights && filteredHighlights.length > 0;
|
const hasHighlights = filteredHighlights && filteredHighlights.length > 0;
|
||||||
if (hasHighlights) {
|
if (hasHighlights) {
|
||||||
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
|
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{value.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
|
||||||
}
|
}
|
||||||
const T0 = Box;
|
const T0 = Box;
|
||||||
const T1 = Text;
|
const T1 = Text;
|
||||||
const t4 = "truncate-end";
|
const t4 = "truncate-end";
|
||||||
const t5 = showPlaceholder && props.placeholderElement ? props.placeholderElement : showPlaceholder && renderedPlaceholder ? <Ansi>{renderedPlaceholder}</Ansi> : <Ansi>{renderedValue}</Ansi>;
|
const t5 = showPlaceholder && props.placeholderElement ? props.placeholderElement : showPlaceholder && renderedPlaceholder ? <Ansi>{renderedPlaceholder}</Ansi> : <Ansi>{renderedValue}</Ansi>;
|
||||||
const t6 = showArgumentHint && <Text dimColor={true}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
|
const t6 = showArgumentHint && <Text dimColor={true}>{value.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
|
||||||
let t7;
|
let t7;
|
||||||
if ($[4] !== T1 || $[5] !== children || $[6] !== props || $[7] !== t5 || $[8] !== t6) {
|
if ($[4] !== T1 || $[5] !== children || $[6] !== props || $[7] !== t5 || $[8] !== t6) {
|
||||||
t7 = <T1 wrap={t4} dimColor={props.dimColor}>{t5}{t6}{children}</T1>;
|
t7 = <T1 wrap={t4} dimColor={props.dimColor}>{t5}{t6}{children}</T1>;
|
||||||
|
|||||||
@@ -252,14 +252,24 @@ function PromptInput({
|
|||||||
show: false
|
show: false
|
||||||
});
|
});
|
||||||
const [cursorOffset, setCursorOffset] = useState<number>(input.length);
|
const [cursorOffset, setCursorOffset] = useState<number>(input.length);
|
||||||
// Track the last input value set via internal handlers so we can detect
|
// Track the last input value set via internal handlers so external updates
|
||||||
// external input changes (e.g. speech-to-text injection) and move cursor to end.
|
// (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);
|
const lastInternalInputRef = React.useRef(input);
|
||||||
if (input !== lastInternalInputRef.current) {
|
const lastPropInputRef = React.useRef(input);
|
||||||
// Input changed externally (not through any internal handler) — move cursor to end
|
React.useLayoutEffect(() => {
|
||||||
setCursorOffset(input.length);
|
if (input === lastPropInputRef.current) {
|
||||||
lastInternalInputRef.current = input;
|
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
|
// Wrap onInputChange to track internal changes before they trigger re-render
|
||||||
const trackAndSetInput = React.useCallback((value: string) => {
|
const trackAndSetInput = React.useCallback((value: string) => {
|
||||||
lastInternalInputRef.current = value;
|
lastInternalInputRef.current = value;
|
||||||
@@ -2201,7 +2211,7 @@ function PromptInput({
|
|||||||
multiline: true,
|
multiline: true,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onChange,
|
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),
|
// History navigation is handled via TextInput props (onHistoryUp/onHistoryDown),
|
||||||
// NOT via useKeybindings. This allows useTextInput's upOrHistoryUp/downOrHistoryDown
|
// NOT via useKeybindings. This allows useTextInput's upOrHistoryUp/downOrHistoryDown
|
||||||
// to try cursor movement first and only fall through to history navigation when the
|
// to try cursor movement first and only fall through to history navigation when the
|
||||||
|
|||||||
231
src/components/TextInput.test.tsx
Normal file
231
src/components/TextInput.test.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { PassThrough } from 'node:stream'
|
||||||
|
|
||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
import React from 'react'
|
||||||
|
import stripAnsi from 'strip-ansi'
|
||||||
|
|
||||||
|
import { createRoot } from '../ink.js'
|
||||||
|
import { AppStateProvider } from '../state/AppState.js'
|
||||||
|
import TextInput from './TextInput.js'
|
||||||
|
import VimTextInput from './VimTextInput.js'
|
||||||
|
|
||||||
|
const SYNC_START = '\x1B[?2026h'
|
||||||
|
const SYNC_END = '\x1B[?2026l'
|
||||||
|
|
||||||
|
function extractLastFrame(output: string): string {
|
||||||
|
let lastFrame: string | null = null
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
while (cursor < output.length) {
|
||||||
|
const start = output.indexOf(SYNC_START, cursor)
|
||||||
|
if (start === -1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentStart = start + SYNC_START.length
|
||||||
|
const end = output.indexOf(SYNC_END, contentStart)
|
||||||
|
if (end === -1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = output.slice(contentStart, end)
|
||||||
|
if (frame.trim().length > 0) {
|
||||||
|
lastFrame = frame
|
||||||
|
}
|
||||||
|
cursor = end + SYNC_END.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastFrame ?? output
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestStreams(): {
|
||||||
|
stdout: PassThrough
|
||||||
|
stdin: PassThrough & {
|
||||||
|
isTTY: boolean
|
||||||
|
setRawMode: (mode: boolean) => void
|
||||||
|
ref: () => void
|
||||||
|
unref: () => void
|
||||||
|
}
|
||||||
|
getOutput: () => string
|
||||||
|
} {
|
||||||
|
let output = ''
|
||||||
|
const stdout = new PassThrough()
|
||||||
|
const stdin = new PassThrough() as PassThrough & {
|
||||||
|
isTTY: boolean
|
||||||
|
setRawMode: (mode: boolean) => void
|
||||||
|
ref: () => void
|
||||||
|
unref: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin.isTTY = true
|
||||||
|
stdin.setRawMode = () => {}
|
||||||
|
stdin.ref = () => {}
|
||||||
|
stdin.unref = () => {}
|
||||||
|
;(stdout as unknown as { columns: number }).columns = 120
|
||||||
|
stdout.on('data', chunk => {
|
||||||
|
output += chunk.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout,
|
||||||
|
stdin,
|
||||||
|
getOutput: () => output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DelayedControlledTextInput(): React.ReactNode {
|
||||||
|
const [value, setValue] = React.useState('')
|
||||||
|
const [cursorOffset, setCursorOffset] = React.useState(0)
|
||||||
|
const valueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const offsetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (valueTimerRef.current) {
|
||||||
|
clearTimeout(valueTimerRef.current)
|
||||||
|
}
|
||||||
|
if (offsetTimerRef.current) {
|
||||||
|
clearTimeout(offsetTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppStateProvider>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChange={nextValue => {
|
||||||
|
if (valueTimerRef.current) {
|
||||||
|
clearTimeout(valueTimerRef.current)
|
||||||
|
}
|
||||||
|
valueTimerRef.current = setTimeout(() => {
|
||||||
|
setValue(nextValue)
|
||||||
|
}, 200)
|
||||||
|
}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Type here..."
|
||||||
|
columns={60}
|
||||||
|
cursorOffset={cursorOffset}
|
||||||
|
onChangeCursorOffset={nextOffset => {
|
||||||
|
if (offsetTimerRef.current) {
|
||||||
|
clearTimeout(offsetTimerRef.current)
|
||||||
|
}
|
||||||
|
offsetTimerRef.current = setTimeout(() => {
|
||||||
|
setCursorOffset(nextOffset)
|
||||||
|
}, 200)
|
||||||
|
}}
|
||||||
|
focus
|
||||||
|
showCursor
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</AppStateProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DelayedControlledVimTextInput(): React.ReactNode {
|
||||||
|
const [value, setValue] = React.useState('')
|
||||||
|
const [cursorOffset, setCursorOffset] = React.useState(0)
|
||||||
|
const valueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const offsetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (valueTimerRef.current) {
|
||||||
|
clearTimeout(valueTimerRef.current)
|
||||||
|
}
|
||||||
|
if (offsetTimerRef.current) {
|
||||||
|
clearTimeout(offsetTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppStateProvider>
|
||||||
|
<VimTextInput
|
||||||
|
value={value}
|
||||||
|
onChange={nextValue => {
|
||||||
|
if (valueTimerRef.current) {
|
||||||
|
clearTimeout(valueTimerRef.current)
|
||||||
|
}
|
||||||
|
valueTimerRef.current = setTimeout(() => {
|
||||||
|
setValue(nextValue)
|
||||||
|
}, 200)
|
||||||
|
}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Type here..."
|
||||||
|
columns={60}
|
||||||
|
cursorOffset={cursorOffset}
|
||||||
|
onChangeCursorOffset={nextOffset => {
|
||||||
|
if (offsetTimerRef.current) {
|
||||||
|
clearTimeout(offsetTimerRef.current)
|
||||||
|
}
|
||||||
|
offsetTimerRef.current = setTimeout(() => {
|
||||||
|
setCursorOffset(nextOffset)
|
||||||
|
}, 200)
|
||||||
|
}}
|
||||||
|
initialMode="INSERT"
|
||||||
|
focus
|
||||||
|
showCursor
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</AppStateProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('TextInput renders typed characters before delayed parent value commits', async () => {
|
||||||
|
const { stdout, stdin, getOutput } = createTestStreams()
|
||||||
|
const root = await createRoot({
|
||||||
|
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||||
|
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||||
|
patchConsole: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
root.render(<DelayedControlledTextInput />)
|
||||||
|
|
||||||
|
await Bun.sleep(50)
|
||||||
|
stdin.write('a')
|
||||||
|
await Bun.sleep(25)
|
||||||
|
stdin.write('b')
|
||||||
|
await Bun.sleep(25)
|
||||||
|
|
||||||
|
const output = stripAnsi(extractLastFrame(getOutput()))
|
||||||
|
|
||||||
|
root.unmount()
|
||||||
|
stdin.end()
|
||||||
|
stdout.end()
|
||||||
|
await Bun.sleep(25)
|
||||||
|
|
||||||
|
expect(output).toContain('ab')
|
||||||
|
expect(output).not.toContain('Type here...')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => {
|
||||||
|
const { stdout, stdin, getOutput } = createTestStreams()
|
||||||
|
const root = await createRoot({
|
||||||
|
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||||
|
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||||
|
patchConsole: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
root.render(<DelayedControlledVimTextInput />)
|
||||||
|
|
||||||
|
await Bun.sleep(50)
|
||||||
|
stdin.write('a')
|
||||||
|
await Bun.sleep(25)
|
||||||
|
stdin.write('s')
|
||||||
|
await Bun.sleep(25)
|
||||||
|
stdin.write('d')
|
||||||
|
await Bun.sleep(25)
|
||||||
|
stdin.write('f')
|
||||||
|
await Bun.sleep(25)
|
||||||
|
|
||||||
|
const output = stripAnsi(extractLastFrame(getOutput()))
|
||||||
|
|
||||||
|
root.unmount()
|
||||||
|
stdin.end()
|
||||||
|
stdout.end()
|
||||||
|
await Bun.sleep(25)
|
||||||
|
|
||||||
|
expect(output).toContain('asdf')
|
||||||
|
expect(output).not.toContain('Type here...')
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
||||||
import type { Command } from '../commands.js'
|
import type { Command } from '../commands.js'
|
||||||
import { useNotifications } from '../context/notifications.js'
|
import { useNotifications } from '../context/notifications.js'
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
} from '../services/analytics/index.js'
|
} from '../services/analytics/index.js'
|
||||||
import { reinitializeLspServerManager } from '../services/lsp/manager.js'
|
import { reinitializeLspServerManager } from '../services/lsp/manager.js'
|
||||||
import { useAppState, useSetAppState } from '../state/AppState.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 type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
|
||||||
import { count } from '../utils/array.js'
|
import { count } from '../utils/array.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
@@ -39,6 +44,11 @@ export function useManagePlugins({
|
|||||||
}: {
|
}: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const pluginCommands = useSyncExternalStore(
|
||||||
|
subscribePluginCommands,
|
||||||
|
getPluginCommandsState,
|
||||||
|
getPluginCommandsState,
|
||||||
|
)
|
||||||
const setAppState = useSetAppState()
|
const setAppState = useSetAppState()
|
||||||
const needsRefresh = useAppState(s => s.plugins.needsRefresh)
|
const needsRefresh = useAppState(s => s.plugins.needsRefresh)
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
@@ -74,6 +84,7 @@ export function useManagePlugins({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
commands = await getPluginCommands()
|
commands = await getPluginCommands()
|
||||||
|
setPluginCommandsState(commands)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
@@ -82,6 +93,7 @@ export function useManagePlugins({
|
|||||||
source: 'plugin-commands',
|
source: 'plugin-commands',
|
||||||
error: `Failed to load plugin commands: ${errorMessage}`,
|
error: `Failed to load plugin commands: ${errorMessage}`,
|
||||||
})
|
})
|
||||||
|
setPluginCommandsState([])
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -173,7 +185,7 @@ export function useManagePlugins({
|
|||||||
...prevState.plugins,
|
...prevState.plugins,
|
||||||
enabled,
|
enabled,
|
||||||
disabled,
|
disabled,
|
||||||
commands,
|
commands: [],
|
||||||
errors: mergedErrors,
|
errors: mergedErrors,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -226,6 +238,7 @@ export function useManagePlugins({
|
|||||||
logError(errorObj)
|
logError(errorObj)
|
||||||
logForDebugging(`Error loading plugins: ${error}`)
|
logForDebugging(`Error loading plugins: ${error}`)
|
||||||
// Set empty state on error, but preserve LSP errors and add the new error
|
// Set empty state on error, but preserve LSP errors and add the new error
|
||||||
|
setPluginCommandsState([])
|
||||||
setAppState(prevState => {
|
setAppState(prevState => {
|
||||||
// Keep existing LSP/non-plugin-loading errors
|
// Keep existing LSP/non-plugin-loading errors
|
||||||
const existingLspErrors = prevState.plugins.errors.filter(
|
const existingLspErrors = prevState.plugins.errors.filter(
|
||||||
@@ -284,6 +297,11 @@ export function useManagePlugins({
|
|||||||
})
|
})
|
||||||
}, [initialPluginLoad, enabled])
|
}, [initialPluginLoad, enabled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) return
|
||||||
|
setPluginCommandsState([])
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
// Plugin state changed on disk (background reconcile, /plugin menu,
|
// Plugin state changed on disk (background reconcile, /plugin menu,
|
||||||
// external settings edit). Show a notification; user runs /reload-plugins
|
// external settings edit). Show a notification; user runs /reload-plugins
|
||||||
// to apply. The previous auto-refresh here had a stale-cache bug (only
|
// 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
|
// Do NOT auto-refresh. Do NOT reset needsRefresh — /reload-plugins
|
||||||
// consumes it via refreshActivePlugins().
|
// consumes it via refreshActivePlugins().
|
||||||
}, [enabled, needsRefresh, addNotification])
|
}, [enabled, needsRefresh, addNotification])
|
||||||
|
|
||||||
|
return enabled ? pluginCommands : []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { isInputModeCharacter } from 'src/components/PromptInput/inputModes.js'
|
import { isInputModeCharacter } from 'src/components/PromptInput/inputModes.js'
|
||||||
import { useNotifications } from 'src/context/notifications.js'
|
import { useNotifications } from 'src/context/notifications.js'
|
||||||
import stripAnsi from 'strip-ansi'
|
import stripAnsi from 'strip-ansi'
|
||||||
@@ -100,9 +101,74 @@ export function useTextInput({
|
|||||||
prewarmModifiers()
|
prewarmModifiers()
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = externalOffset
|
// Keep a local text/cursor mirror so consecutive keystrokes can advance
|
||||||
const setOffset = onOffsetChange
|
// immediately even if the controlled parent value hasn't committed yet.
|
||||||
const cursor = Cursor.fromText(originalValue, columns, offset)
|
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 { addNotification, removeNotification } = useNotifications()
|
||||||
|
|
||||||
const handleCtrlC = useDoublePress(
|
const handleCtrlC = useDoublePress(
|
||||||
@@ -111,9 +177,11 @@ export function useTextInput({
|
|||||||
},
|
},
|
||||||
() => onExit?.(),
|
() => onExit?.(),
|
||||||
() => {
|
() => {
|
||||||
if (originalValue) {
|
const currentValue = getLiveValue()
|
||||||
|
if (currentValue) {
|
||||||
|
updateRenderedInput('', 0)
|
||||||
onChange('')
|
onChange('')
|
||||||
setOffset(0)
|
onOffsetChange(0)
|
||||||
onHistoryReset?.()
|
onHistoryReset?.()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -125,7 +193,8 @@ export function useTextInput({
|
|||||||
// not dialog dismissal, and needs the double-press safety mechanism.
|
// not dialog dismissal, and needs the double-press safety mechanism.
|
||||||
const handleEscape = useDoublePress(
|
const handleEscape = useDoublePress(
|
||||||
(show: boolean) => {
|
(show: boolean) => {
|
||||||
if (!originalValue || !show) {
|
const currentValue = getLiveValue()
|
||||||
|
if (!currentValue || !show) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -136,17 +205,19 @@ export function useTextInput({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
const currentValue = getLiveValue()
|
||||||
// Remove the "Esc again to clear" notification immediately
|
// Remove the "Esc again to clear" notification immediately
|
||||||
removeNotification('escape-again-to-clear')
|
removeNotification('escape-again-to-clear')
|
||||||
onClearInput?.()
|
onClearInput?.()
|
||||||
if (originalValue) {
|
if (currentValue) {
|
||||||
// Track double-escape usage for feature discovery
|
// Track double-escape usage for feature discovery
|
||||||
// Save to history before clearing
|
// Save to history before clearing
|
||||||
if (originalValue.trim() !== '') {
|
if (currentValue.trim() !== '') {
|
||||||
addToHistory(originalValue)
|
addToHistory(currentValue)
|
||||||
}
|
}
|
||||||
|
updateRenderedInput('', 0)
|
||||||
onChange('')
|
onChange('')
|
||||||
setOffset(0)
|
onOffsetChange(0)
|
||||||
onHistoryReset?.()
|
onHistoryReset?.()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -154,13 +225,13 @@ export function useTextInput({
|
|||||||
|
|
||||||
const handleEmptyCtrlD = useDoublePress(
|
const handleEmptyCtrlD = useDoublePress(
|
||||||
show => {
|
show => {
|
||||||
if (originalValue !== '') {
|
if (getLiveValue() !== '') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onExitMessage?.(show, 'Ctrl-D')
|
onExitMessage?.(show, 'Ctrl-D')
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
if (originalValue !== '') {
|
if (getLiveValue() !== '') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onExit?.()
|
onExit?.()
|
||||||
@@ -168,6 +239,7 @@ export function useTextInput({
|
|||||||
)
|
)
|
||||||
|
|
||||||
function handleCtrlD(): MaybeCursor {
|
function handleCtrlD(): MaybeCursor {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
if (cursor.text === '') {
|
if (cursor.text === '') {
|
||||||
// When input is empty, handle double-press
|
// When input is empty, handle double-press
|
||||||
handleEmptyCtrlD()
|
handleEmptyCtrlD()
|
||||||
@@ -178,24 +250,28 @@ export function useTextInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function killToLineEnd(): Cursor {
|
function killToLineEnd(): Cursor {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
|
const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
|
||||||
pushToKillRing(killed, 'append')
|
pushToKillRing(killed, 'append')
|
||||||
return newCursor
|
return newCursor
|
||||||
}
|
}
|
||||||
|
|
||||||
function killToLineStart(): Cursor {
|
function killToLineStart(): Cursor {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
const { cursor: newCursor, killed } = cursor.deleteToLineStart()
|
const { cursor: newCursor, killed } = cursor.deleteToLineStart()
|
||||||
pushToKillRing(killed, 'prepend')
|
pushToKillRing(killed, 'prepend')
|
||||||
return newCursor
|
return newCursor
|
||||||
}
|
}
|
||||||
|
|
||||||
function killWordBefore(): Cursor {
|
function killWordBefore(): Cursor {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
|
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
|
||||||
pushToKillRing(killed, 'prepend')
|
pushToKillRing(killed, 'prepend')
|
||||||
return newCursor
|
return newCursor
|
||||||
}
|
}
|
||||||
|
|
||||||
function yank(): Cursor {
|
function yank(): Cursor {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
const text = getLastKill()
|
const text = getLastKill()
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
const startOffset = cursor.offset
|
const startOffset = cursor.offset
|
||||||
@@ -207,6 +283,7 @@ export function useTextInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleYankPop(): Cursor {
|
function handleYankPop(): Cursor {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
const popResult = yankPop()
|
const popResult = yankPop()
|
||||||
if (!popResult) {
|
if (!popResult) {
|
||||||
return cursor
|
return cursor
|
||||||
@@ -222,13 +299,16 @@ export function useTextInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCtrl = mapInput([
|
const handleCtrl = mapInput([
|
||||||
['a', () => cursor.startOfLine()],
|
['a', () => getLiveCursor().startOfLine()],
|
||||||
['b', () => cursor.left()],
|
['b', () => getLiveCursor().left()],
|
||||||
['c', handleCtrlC],
|
['c', handleCtrlC],
|
||||||
['d', handleCtrlD],
|
['d', handleCtrlD],
|
||||||
['e', () => cursor.endOfLine()],
|
['e', () => getLiveCursor().endOfLine()],
|
||||||
['f', () => cursor.right()],
|
['f', () => getLiveCursor().right()],
|
||||||
['h', () => cursor.deleteTokenBefore() ?? cursor.backspace()],
|
['h', () => {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
|
return cursor.deleteTokenBefore() ?? cursor.backspace()
|
||||||
|
}],
|
||||||
['k', killToLineEnd],
|
['k', killToLineEnd],
|
||||||
['n', () => downOrHistoryDown()],
|
['n', () => downOrHistoryDown()],
|
||||||
['p', () => upOrHistoryUp()],
|
['p', () => upOrHistoryUp()],
|
||||||
@@ -238,13 +318,15 @@ export function useTextInput({
|
|||||||
])
|
])
|
||||||
|
|
||||||
const handleMeta = mapInput([
|
const handleMeta = mapInput([
|
||||||
['b', () => cursor.prevWord()],
|
['b', () => getLiveCursor().prevWord()],
|
||||||
['f', () => cursor.nextWord()],
|
['f', () => getLiveCursor().nextWord()],
|
||||||
['d', () => cursor.deleteWordAfter()],
|
['d', () => getLiveCursor().deleteWordAfter()],
|
||||||
['y', handleYankPop],
|
['y', handleYankPop],
|
||||||
])
|
])
|
||||||
|
|
||||||
function handleEnter(key: Key) {
|
function handleEnter(key: Key) {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
|
const currentValue = getLiveValue()
|
||||||
if (
|
if (
|
||||||
multiline &&
|
multiline &&
|
||||||
cursor.offset > 0 &&
|
cursor.offset > 0 &&
|
||||||
@@ -263,10 +345,11 @@ export function useTextInput({
|
|||||||
if (env.terminal === 'Apple_Terminal' && isModifierPressed('shift')) {
|
if (env.terminal === 'Apple_Terminal' && isModifierPressed('shift')) {
|
||||||
return cursor.insert('\n')
|
return cursor.insert('\n')
|
||||||
}
|
}
|
||||||
onSubmit?.(originalValue)
|
onSubmit?.(currentValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
function upOrHistoryUp() {
|
function upOrHistoryUp() {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
if (disableCursorMovementForUpDownKeys) {
|
if (disableCursorMovementForUpDownKeys) {
|
||||||
onHistoryUp?.()
|
onHistoryUp?.()
|
||||||
return cursor
|
return cursor
|
||||||
@@ -291,6 +374,7 @@ export function useTextInput({
|
|||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
function downOrHistoryDown() {
|
function downOrHistoryDown() {
|
||||||
|
const cursor = getLiveCursor()
|
||||||
if (disableCursorMovementForUpDownKeys) {
|
if (disableCursorMovementForUpDownKeys) {
|
||||||
onHistoryDown?.()
|
onHistoryDown?.()
|
||||||
return cursor
|
return cursor
|
||||||
@@ -315,7 +399,7 @@ export function useTextInput({
|
|||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapKey(key: Key): InputMapper {
|
function mapKey(key: Key, cursor: Cursor): InputMapper {
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case key.escape:
|
case key.escape:
|
||||||
return () => {
|
return () => {
|
||||||
@@ -429,6 +513,7 @@ export function useTextInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onInput(input: string, key: Key): void {
|
function onInput(input: string, key: Key): void {
|
||||||
|
const currentCursor = getLiveCursor()
|
||||||
// Note: Image paste shortcut (chat:imagePaste) is handled via useKeybindings in PromptInput
|
// Note: Image paste shortcut (chat:imagePaste) is handled via useKeybindings in PromptInput
|
||||||
|
|
||||||
// Apply filter if provided
|
// Apply filter if provided
|
||||||
@@ -446,18 +531,15 @@ export function useTextInput({
|
|||||||
|
|
||||||
// Apply all DEL characters as backspace operations synchronously
|
// Apply all DEL characters as backspace operations synchronously
|
||||||
// Try to delete tokens first, fall back to character backspace
|
// Try to delete tokens first, fall back to character backspace
|
||||||
let currentCursor = cursor
|
let nextCursor = currentCursor
|
||||||
for (let i = 0; i < delCount; i++) {
|
for (let i = 0; i < delCount; i++) {
|
||||||
currentCursor =
|
nextCursor =
|
||||||
currentCursor.deleteTokenBefore() ?? currentCursor.backspace()
|
nextCursor.deleteTokenBefore() ?? nextCursor.backspace()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state once with the final result
|
// Update state once with the final result
|
||||||
if (!cursor.equals(currentCursor)) {
|
if (!currentCursor.equals(nextCursor)) {
|
||||||
if (cursor.text !== currentCursor.text) {
|
setValue(nextCursor.text, nextCursor.offset)
|
||||||
onChange(currentCursor.text)
|
|
||||||
}
|
|
||||||
setOffset(currentCursor.offset)
|
|
||||||
}
|
}
|
||||||
resetKillAccumulation()
|
resetKillAccumulation()
|
||||||
resetYankState()
|
resetYankState()
|
||||||
@@ -474,13 +556,10 @@ export function useTextInput({
|
|||||||
resetYankState()
|
resetYankState()
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextCursor = mapKey(key)(filteredInput)
|
const nextCursor = mapKey(key, currentCursor)(filteredInput)
|
||||||
if (nextCursor) {
|
if (nextCursor) {
|
||||||
if (!cursor.equals(nextCursor)) {
|
if (!currentCursor.equals(nextCursor)) {
|
||||||
if (cursor.text !== nextCursor.text) {
|
setValue(nextCursor.text, nextCursor.offset)
|
||||||
onChange(nextCursor.text)
|
|
||||||
}
|
|
||||||
setOffset(nextCursor.offset)
|
|
||||||
}
|
}
|
||||||
// SSH-coalesced Enter: on slow links, "o" + Enter can arrive as one
|
// SSH-coalesced Enter: on slow links, "o" + Enter can arrive as one
|
||||||
// chunk "o\r". parseKeypress only matches s === '\r', so it hit the
|
// chunk "o\r". parseKeypress only matches s === '\r', so it hit the
|
||||||
@@ -512,6 +591,7 @@ export function useTextInput({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
onInput,
|
onInput,
|
||||||
|
value,
|
||||||
renderedValue: cursor.render(
|
renderedValue: cursor.render(
|
||||||
cursorChar,
|
cursorChar,
|
||||||
mask,
|
mask,
|
||||||
@@ -520,6 +600,7 @@ export function useTextInput({
|
|||||||
maxVisibleLines,
|
maxVisibleLines,
|
||||||
),
|
),
|
||||||
offset,
|
offset,
|
||||||
|
setValue,
|
||||||
setOffset,
|
setOffset,
|
||||||
cursorLine: cursorPos.line - cursor.getViewportStartLine(maxVisibleLines),
|
cursorLine: cursorPos.line - cursor.getViewportStartLine(maxVisibleLines),
|
||||||
cursorColumn: cursorPos.column,
|
cursorColumn: cursorPos.column,
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ export function useVimInput(props: UseVimInputProps): VimInputState {
|
|||||||
// Vim behavior: move cursor left by 1 when exiting insert mode
|
// Vim behavior: move cursor left by 1 when exiting insert mode
|
||||||
// (unless at beginning of line or at offset 0)
|
// (unless at beginning of line or at offset 0)
|
||||||
const offset = textInput.offset
|
const offset = textInput.offset
|
||||||
if (offset > 0 && props.value[offset - 1] !== '\n') {
|
if (offset > 0 && textInput.value[offset - 1] !== '\n') {
|
||||||
textInput.setOffset(offset - 1)
|
textInput.setOffset(offset - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||||
setMode('NORMAL')
|
setMode('NORMAL')
|
||||||
onModeChange?.('NORMAL')
|
onModeChange?.('NORMAL')
|
||||||
}, [onModeChange, textInput, props.value])
|
}, [onModeChange, textInput])
|
||||||
|
|
||||||
function createOperatorContext(
|
function createOperatorContext(
|
||||||
cursor: Cursor,
|
cursor: Cursor,
|
||||||
@@ -85,8 +85,8 @@ export function useVimInput(props: UseVimInputProps): VimInputState {
|
|||||||
): OperatorContext {
|
): OperatorContext {
|
||||||
return {
|
return {
|
||||||
cursor,
|
cursor,
|
||||||
text: props.value,
|
text: textInput.value,
|
||||||
setText: (newText: string) => props.onChange(newText),
|
setText: (newText: string) => textInput.setValue(newText),
|
||||||
setOffset: (offset: number) => textInput.setOffset(offset),
|
setOffset: (offset: number) => textInput.setOffset(offset),
|
||||||
enterInsert: (offset: number) => switchToInsertMode(offset),
|
enterInsert: (offset: number) => switchToInsertMode(offset),
|
||||||
getRegister: () => persistentRef.current.register,
|
getRegister: () => persistentRef.current.register,
|
||||||
@@ -110,15 +110,18 @@ export function useVimInput(props: UseVimInputProps): VimInputState {
|
|||||||
const change = persistentRef.current.lastChange
|
const change = persistentRef.current.lastChange
|
||||||
if (!change) return
|
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)
|
const ctx = createOperatorContext(cursor, true)
|
||||||
|
|
||||||
switch (change.type) {
|
switch (change.type) {
|
||||||
case 'insert':
|
case 'insert':
|
||||||
if (change.text) {
|
if (change.text) {
|
||||||
const newCursor = cursor.insert(change.text)
|
const newCursor = cursor.insert(change.text)
|
||||||
props.onChange(newCursor.text)
|
textInput.setValue(newCursor.text, newCursor.offset)
|
||||||
textInput.setOffset(newCursor.offset)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -179,7 +182,11 @@ export function useVimInput(props: UseVimInputProps): VimInputState {
|
|||||||
// lookups expect single chars and a prepended space would break them.
|
// lookups expect single chars and a prepended space would break them.
|
||||||
const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput
|
const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput
|
||||||
const input = state.mode === 'INSERT' ? filtered : 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) {
|
if (key.ctrl) {
|
||||||
textInput.onInput(input, key)
|
textInput.onInput(input, key)
|
||||||
|
|||||||
@@ -115,7 +115,10 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
keyParseState = INITIAL_STATE;
|
keyParseState = INITIAL_STATE;
|
||||||
// Timer for flushing incomplete escape sequences
|
// Timer for flushing incomplete escape sequences
|
||||||
incompleteEscapeTimer: NodeJS.Timeout | null = null;
|
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)
|
// Timeout durations for incomplete sequences (ms)
|
||||||
readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences
|
readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences
|
||||||
readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations
|
readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import createRenderer, { type Renderer } from './renderer.js';
|
|||||||
import { CellWidth, CharPool, cellAt, createScreen, HyperlinkPool, isEmptyCellAt, migrateScreenPools, StylePool } from './screen.js';
|
import { CellWidth, CharPool, cellAt, createScreen, HyperlinkPool, isEmptyCellAt, migrateScreenPools, StylePool } from './screen.js';
|
||||||
import { applySearchHighlight } from './searchHighlight.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 { 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 { 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 { 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';
|
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 tDiff = performance.now();
|
||||||
|
const rewriteMainScreen = !this.altScreenActive && shouldUseMainScreenRewrite();
|
||||||
const diff = this.log.render(prevFrame, frame, this.altScreenActive,
|
const diff = this.log.render(prevFrame, frame, this.altScreenActive,
|
||||||
// DECSTBM needs BSU/ESU atomicity — without it the outer terminal
|
// DECSTBM needs BSU/ESU atomicity — without it the outer terminal
|
||||||
// renders the scrolled-but-not-yet-repainted intermediate state.
|
// renders the scrolled-but-not-yet-repainted intermediate state.
|
||||||
// tmux is the main case (re-emits DECSTBM with its own timing and
|
// tmux is the main case (re-emits DECSTBM with its own timing and
|
||||||
// doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false).
|
// doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false).
|
||||||
SYNC_OUTPUT_SUPPORTED);
|
SYNC_OUTPUT_SUPPORTED, rewriteMainScreen);
|
||||||
const diffMs = performance.now() - tDiff;
|
const diffMs = performance.now() - tDiff;
|
||||||
// Swap buffers
|
// Swap buffers
|
||||||
this.backFrame = this.frontFrame;
|
this.backFrame = this.frontFrame;
|
||||||
@@ -759,7 +760,8 @@ export default class Ink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tWrite = performance.now();
|
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;
|
const writeMs = performance.now() - tWrite;
|
||||||
|
|
||||||
// Update blit safety for the NEXT frame. The frame just rendered
|
// Update blit safety for the NEXT frame. The frame just rendered
|
||||||
|
|||||||
125
src/ink/log-update.test.ts
Normal file
125
src/ink/log-update.test.ts
Normal file
@@ -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<LogUpdate['render']>): 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')
|
||||||
|
})
|
||||||
@@ -125,6 +125,7 @@ export class LogUpdate {
|
|||||||
next: Frame,
|
next: Frame,
|
||||||
altScreen = false,
|
altScreen = false,
|
||||||
decstbmSafe = true,
|
decstbmSafe = true,
|
||||||
|
rewriteMainScreen = false,
|
||||||
): Diff {
|
): Diff {
|
||||||
if (!this.options.isTTY) {
|
if (!this.options.isTTY) {
|
||||||
return this.renderFullFrame(next)
|
return this.renderFullFrame(next)
|
||||||
@@ -146,6 +147,13 @@ export class LogUpdate {
|
|||||||
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
|
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,
|
// DECSTBM scroll optimization: when a ScrollBox's scrollTop changed,
|
||||||
// shift content with a hardware scroll (CSI top;bot r + CSI n S/T)
|
// shift content with a hardware scroll (CSI top;bot r + CSI n S/T)
|
||||||
// instead of rewriting the whole scroll region. The shiftRows on
|
// 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
|
// Main screen: if cursor needs to be past the last line of content
|
||||||
// (typical: cursor.y = screen.height), emit \n to create that line
|
// (typical: cursor.y = screen.height), emit \n to create that line
|
||||||
// since cursor movement can't create new lines.
|
// since cursor movement can't create new lines.
|
||||||
if (altScreen) {
|
if (!altScreen) {
|
||||||
// no-op; next frame's CSI H anchors cursor
|
restoreMainScreenCursor(screen, next)
|
||||||
} 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<Diff[number]>(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = performance.now() - startTime
|
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(
|
function transitionHyperlink(
|
||||||
diff: Diff,
|
diff: Diff,
|
||||||
current: Hyperlink,
|
current: Hyperlink,
|
||||||
@@ -622,6 +675,37 @@ function renderFrameSlice(
|
|||||||
return screen
|
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<Diff[number]>(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 }
|
type Delta = { dx: number; dy: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ export function setXtversionName(name: string): void {
|
|||||||
if (xtversionName === undefined) xtversionName = name
|
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
|
/** True if running in an xterm.js-based terminal (VS Code, Cursor, Windsurf
|
||||||
* integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but
|
* integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but
|
||||||
* not forwarded over SSH) with the XTVERSION probe result (async, survives
|
* 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
|
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
|
// Terminals known to correctly implement the Kitty keyboard protocol
|
||||||
// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
|
// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
|
||||||
// disambiguation. We previously enabled unconditionally (#23350), assuming
|
// disambiguation. We previously enabled unconditionally (#23350), assuming
|
||||||
|
|||||||
@@ -617,7 +617,6 @@ export function REPL({
|
|||||||
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
|
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
|
||||||
const verbose = useAppState(s => s.verbose);
|
const verbose = useAppState(s => s.verbose);
|
||||||
const mcp = useAppState(s => s.mcp);
|
const mcp = useAppState(s => s.mcp);
|
||||||
const plugins = useAppState(s => s.plugins);
|
|
||||||
const agentDefinitions = useAppState(s => s.agentDefinitions);
|
const agentDefinitions = useAppState(s => s.agentDefinitions);
|
||||||
const fileHistory = useAppState(s => s.fileHistory);
|
const fileHistory = useAppState(s => s.fileHistory);
|
||||||
const initialMessage = useAppState(s => s.initialMessage);
|
const initialMessage = useAppState(s => s.initialMessage);
|
||||||
@@ -780,7 +779,7 @@ export function REPL({
|
|||||||
}, [localTools, initialTools]);
|
}, [localTools, initialTools]);
|
||||||
|
|
||||||
// Initialize plugin management
|
// Initialize plugin management
|
||||||
useManagePlugins({
|
const pluginCommands = useManagePlugins({
|
||||||
enabled: !isRemoteSession
|
enabled: !isRemoteSession
|
||||||
});
|
});
|
||||||
const tasksV2 = useTasksV2WithCollapseEffect();
|
const tasksV2 = useTasksV2WithCollapseEffect();
|
||||||
@@ -826,10 +825,16 @@ export function REPL({
|
|||||||
}, [mainThreadAgentDefinition, mergedTools]);
|
}, [mainThreadAgentDefinition, mergedTools]);
|
||||||
|
|
||||||
// Merge commands from local state, plugins, and MCP
|
// 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[]);
|
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
|
// Filter out all commands if disableSlashCommands is true
|
||||||
const commands = useMemo(() => disableSlashCommands ? [] : mergedCommands, [disableSlashCommands, mergedCommands]);
|
const commands = useMemo(() => disableSlashCommands ? [] : mergedCommands, [disableSlashCommands, mergedCommands]);
|
||||||
|
const renderCommands = useMemo(() => disableSlashCommands ? [] : renderMergedCommands, [disableSlashCommands, renderMergedCommands]);
|
||||||
useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients);
|
useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients);
|
||||||
useIdeSelection(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, setIDESelection);
|
useIdeSelection(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, setIDESelection);
|
||||||
const [streamMode, setStreamMode] = useState<SpinnerMode>('responding');
|
const [streamMode, setStreamMode] = useState<SpinnerMode>('responding');
|
||||||
@@ -4427,7 +4432,7 @@ export function REPL({
|
|||||||
// and transcript-mode are mutually exclusive (this early return), so
|
// and transcript-mode are mutually exclusive (this early return), so
|
||||||
// only one ScrollBox is ever mounted at a time.
|
// only one ScrollBox is ever mounted at a time.
|
||||||
const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined;
|
const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined;
|
||||||
const transcriptMessagesElement = <Messages messages={transcriptMessages} tools={tools} commands={commands} verbose={true} toolJSX={null} toolUseConfirmQueue={[]} inProgressToolUseIDs={inProgressToolUseIDs} isMessageSelectorVisible={false} conversationId={conversationId} screen={screen} agentDefinitions={agentDefinitions} streamingToolUses={transcriptStreamingToolUses} showAllInTranscript={showAllInTranscript} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} hidePastThinking={true} streamingThinking={streamingThinking} scrollRef={transcriptScrollRef} jumpRef={jumpRef} onSearchMatchesChange={onSearchMatchesChange} scanElement={scanElement} setPositions={setPositions} disableRenderCap={dumpMode} />;
|
const transcriptMessagesElement = <Messages messages={transcriptMessages} tools={tools} commands={renderCommands} verbose={true} toolJSX={null} toolUseConfirmQueue={[]} inProgressToolUseIDs={inProgressToolUseIDs} isMessageSelectorVisible={false} conversationId={conversationId} screen={screen} agentDefinitions={agentDefinitions} streamingToolUses={transcriptStreamingToolUses} showAllInTranscript={showAllInTranscript} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} hidePastThinking={true} streamingThinking={streamingThinking} scrollRef={transcriptScrollRef} jumpRef={jumpRef} onSearchMatchesChange={onSearchMatchesChange} scanElement={scanElement} setPositions={setPositions} disableRenderCap={dumpMode} />;
|
||||||
const transcriptToolJSX = toolJSX && <Box flexDirection="column" width="100%">
|
const transcriptToolJSX = toolJSX && <Box flexDirection="column" width="100%">
|
||||||
{toolJSX.jsx}
|
{toolJSX.jsx}
|
||||||
</Box>;
|
</Box>;
|
||||||
@@ -4595,7 +4600,7 @@ export function REPL({
|
|||||||
jumpToNew(scrollRef.current);
|
jumpToNew(scrollRef.current);
|
||||||
}} scrollable={<>
|
}} scrollable={<>
|
||||||
<TeammateViewHeader />
|
<TeammateViewHeader />
|
||||||
<Messages messages={displayedMessages} tools={tools} commands={commands} verbose={verbose} toolJSX={toolJSX} toolUseConfirmQueue={toolUseConfirmQueue} inProgressToolUseIDs={viewedTeammateTask ? viewedTeammateTask.inProgressToolUseIDs ?? new Set() : inProgressToolUseIDs} isMessageSelectorVisible={isMessageSelectorVisible} conversationId={conversationId} screen={screen} streamingToolUses={streamingToolUses} showAllInTranscript={showAllInTranscript} agentDefinitions={agentDefinitions} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} streamingText={isLoading && !viewedAgentTask ? visibleStreamingText : null} isBriefOnly={viewedAgentTask ? false : isBriefOnly} unseenDivider={viewedAgentTask ? undefined : unseenDivider} scrollRef={isFullscreenEnvEnabled() ? scrollRef : undefined} trackStickyPrompt={isFullscreenEnvEnabled() ? true : undefined} cursor={cursor} setCursor={setCursor} cursorNavRef={cursorNavRef} />
|
<Messages messages={displayedMessages} tools={tools} commands={renderCommands} verbose={verbose} toolJSX={toolJSX} toolUseConfirmQueue={toolUseConfirmQueue} inProgressToolUseIDs={viewedTeammateTask ? viewedTeammateTask.inProgressToolUseIDs ?? new Set() : inProgressToolUseIDs} isMessageSelectorVisible={isMessageSelectorVisible} conversationId={conversationId} screen={screen} streamingToolUses={streamingToolUses} showAllInTranscript={showAllInTranscript} agentDefinitions={agentDefinitions} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} streamingText={isLoading && !viewedAgentTask ? visibleStreamingText : null} isBriefOnly={viewedAgentTask ? false : isBriefOnly} unseenDivider={viewedAgentTask ? undefined : unseenDivider} scrollRef={isFullscreenEnvEnabled() ? scrollRef : undefined} trackStickyPrompt={isFullscreenEnvEnabled() ? true : undefined} cursor={cursor} setCursor={setCursor} cursorNavRef={cursorNavRef} />
|
||||||
<AwsAuthStatusBox />
|
<AwsAuthStatusBox />
|
||||||
{/* Hide the processing placeholder while a modal is showing —
|
{/* Hide the processing placeholder while a modal is showing —
|
||||||
it would sit at the last visible transcript row right above
|
it would sit at the last visible transcript row right above
|
||||||
@@ -4928,7 +4933,7 @@ export function REPL({
|
|||||||
{"external" === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
{"external" === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
||||||
{showIssueFlagBanner && <IssueFlagBanner />}
|
{showIssueFlagBanner && <IssueFlagBanner />}
|
||||||
{ }
|
{ }
|
||||||
<PromptInput debug={debug} ideSelection={ideSelection} hasSuppressedDialogs={!!hasSuppressedDialogs} isLocalJSXCommandActive={isShowingLocalJSXCommand} getToolUseContext={getToolUseContext} toolPermissionContext={toolPermissionContext} setToolPermissionContext={setToolPermissionContext} apiKeyStatus={apiKeyStatus} commands={commands} agents={agentDefinitions.activeAgents} isLoading={isLoading} onExit={handleExit} verbose={verbose} messages={messages} onAutoUpdaterResult={setAutoUpdaterResult} autoUpdaterResult={autoUpdaterResult} input={inputValue} onInputChange={setInputValue} mode={inputMode} onModeChange={setInputMode} stashedPrompt={stashedPrompt} setStashedPrompt={setStashedPrompt} submitCount={submitCount} onShowMessageSelector={handleShowMessageSelector} onMessageActionsEnter={
|
<PromptInput debug={debug} ideSelection={ideSelection} hasSuppressedDialogs={!!hasSuppressedDialogs} isLocalJSXCommandActive={isShowingLocalJSXCommand} getToolUseContext={getToolUseContext} toolPermissionContext={toolPermissionContext} setToolPermissionContext={setToolPermissionContext} apiKeyStatus={apiKeyStatus} commands={renderCommands} agents={agentDefinitions.activeAgents} isLoading={isLoading} onExit={handleExit} verbose={verbose} messages={messages} onAutoUpdaterResult={setAutoUpdaterResult} autoUpdaterResult={autoUpdaterResult} input={inputValue} onInputChange={setInputValue} mode={inputMode} onModeChange={setInputMode} stashedPrompt={stashedPrompt} setStashedPrompt={setStashedPrompt} submitCount={submitCount} onShowMessageSelector={handleShowMessageSelector} onMessageActionsEnter={
|
||||||
// Works during isLoading — edit cancels first; uuid selection survives appends.
|
// Works during isLoading — edit cancels first; uuid selection survives appends.
|
||||||
feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? enterMessageActions : undefined} mcpClients={mcpClients} pastedContents={pastedContents} setPastedContents={setPastedContents} vimMode={vimMode} setVimMode={setVimMode} showBashesDialog={showBashesDialog} setShowBashesDialog={setShowBashesDialog} onSubmit={onSubmit} onAgentSubmit={onAgentSubmit} isSearchingHistory={isSearchingHistory} setIsSearchingHistory={setIsSearchingHistory} helpOpen={isHelpOpen} setHelpOpen={setIsHelpOpen} insertTextRef={feature('VOICE_MODE') ? insertTextRef : undefined} voiceInterimRange={voice.interimRange} />
|
feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? enterMessageActions : undefined} mcpClients={mcpClients} pastedContents={pastedContents} setPastedContents={setPastedContents} vimMode={vimMode} setVimMode={setVimMode} showBashesDialog={showBashesDialog} setShowBashesDialog={setShowBashesDialog} onSubmit={onSubmit} onAgentSubmit={onAgentSubmit} isSearchingHistory={isSearchingHistory} setIsSearchingHistory={setIsSearchingHistory} helpOpen={isHelpOpen} setHelpOpen={setIsHelpOpen} insertTextRef={feature('VOICE_MODE') ? insertTextRef : undefined} voiceInterimRange={voice.interimRange} />
|
||||||
<SessionBackgroundHint onBackgroundSession={handleBackgroundSession} isLoading={isLoading} />
|
<SessionBackgroundHint onBackgroundSession={handleBackgroundSession} isLoading={isLoading} />
|
||||||
|
|||||||
13
src/state/pluginCommandsStore.ts
Normal file
13
src/state/pluginCommandsStore.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Command } from '../commands.js'
|
||||||
|
import { createStore } from './store.js'
|
||||||
|
|
||||||
|
const pluginCommandsStore = createStore<Command[]>([])
|
||||||
|
|
||||||
|
export const getPluginCommandsState = (): Command[] =>
|
||||||
|
pluginCommandsStore.getState()
|
||||||
|
|
||||||
|
export const subscribePluginCommands = pluginCommandsStore.subscribe
|
||||||
|
|
||||||
|
export function setPluginCommandsState(commands: Command[]): void {
|
||||||
|
pluginCommandsStore.setState(() => [...commands])
|
||||||
|
}
|
||||||
@@ -1,6 +1,29 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import type { Command } from '../../commands.js'
|
||||||
import { SkillTool } from './SkillTool.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', () => {
|
describe('SkillTool missing parameter handling', () => {
|
||||||
test('missing skill stays required at the schema level', async () => {
|
test('missing skill stays required at the schema level', async () => {
|
||||||
@@ -29,3 +52,47 @@ describe('SkillTool missing parameter handling', () => {
|
|||||||
expect(parsed.success).toBe(true)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ export function renderToolUseMessage({
|
|||||||
if (!skill) {
|
if (!skill) {
|
||||||
return null;
|
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 command = commands?.find(c => c.name === skill);
|
||||||
const displayName = command?.loadedFrom === 'commands_DEPRECATED' ? `/${skill}` : skill;
|
const displayName = command?.loadedFrom === 'commands_DEPRECATED' ? `/${skill}` : skill;
|
||||||
return displayName;
|
return displayName;
|
||||||
|
|||||||
@@ -226,8 +226,10 @@ export type VimMode = 'INSERT' | 'NORMAL'
|
|||||||
*/
|
*/
|
||||||
export type BaseInputState = {
|
export type BaseInputState = {
|
||||||
onInput: (input: string, key: Key) => void
|
onInput: (input: string, key: Key) => void
|
||||||
|
value: string
|
||||||
renderedValue: string
|
renderedValue: string
|
||||||
offset: number
|
offset: number
|
||||||
|
setValue: (value: string, offset?: number) => void
|
||||||
setOffset: (offset: number) => void
|
setOffset: (offset: number) => void
|
||||||
/** Cursor line (0-indexed) within the rendered text, accounting for wrapping. */
|
/** Cursor line (0-indexed) within the rendered text, accounting for wrapping. */
|
||||||
cursorLine: number
|
cursorLine: number
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { getOriginalCwd } from '../../bootstrap/state.js'
|
|||||||
import type { Command } from '../../commands.js'
|
import type { Command } from '../../commands.js'
|
||||||
import { reinitializeLspServerManager } from '../../services/lsp/manager.js'
|
import { reinitializeLspServerManager } from '../../services/lsp/manager.js'
|
||||||
import type { AppState } from '../../state/AppState.js'
|
import type { AppState } from '../../state/AppState.js'
|
||||||
|
import { setPluginCommandsState } from '../../state/pluginCommandsStore.js'
|
||||||
import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js'
|
import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js'
|
||||||
import { getAgentDefinitionsWithOverrides } from '../../tools/AgentTool/loadAgentsDir.js'
|
import { getAgentDefinitionsWithOverrides } from '../../tools/AgentTool/loadAgentsDir.js'
|
||||||
import type { PluginError } from '../../types/plugin.js'
|
import type { PluginError } from '../../types/plugin.js'
|
||||||
@@ -92,6 +93,7 @@ export async function refreshActivePlugins(
|
|||||||
])
|
])
|
||||||
|
|
||||||
const { enabled, disabled, errors } = pluginResult
|
const { enabled, disabled, errors } = pluginResult
|
||||||
|
setPluginCommandsState(pluginCommands)
|
||||||
|
|
||||||
// Populate mcpServers/lspServers on each enabled plugin. These are lazy
|
// Populate mcpServers/lspServers on each enabled plugin. These are lazy
|
||||||
// cache slots NOT filled by loadAllPlugins() — they're written later by
|
// cache slots NOT filled by loadAllPlugins() — they're written later by
|
||||||
@@ -126,7 +128,7 @@ export async function refreshActivePlugins(
|
|||||||
...prev.plugins,
|
...prev.plugins,
|
||||||
enabled,
|
enabled,
|
||||||
disabled,
|
disabled,
|
||||||
commands: pluginCommands,
|
commands: [],
|
||||||
errors: mergePluginErrors(prev.plugins.errors, errors),
|
errors: mergePluginErrors(prev.plugins.errors, errors),
|
||||||
needsRefresh: false,
|
needsRefresh: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user