diff --git a/apps/cli/src/ui/components/GoalStatusPanel.test.ts b/apps/cli/src/ui/components/GoalStatusPanel.test.ts index 59fb411ed..8c590a091 100644 --- a/apps/cli/src/ui/components/GoalStatusPanel.test.ts +++ b/apps/cli/src/ui/components/GoalStatusPanel.test.ts @@ -47,8 +47,8 @@ describe('buildGoalStatusLineForTests', () => { }) expect(out).not.toBeNull() expect(out!.label).toBe('scheduled') - expect(out!.line).toContain('轮 2/5') - expect(out!.line).toContain('下轮 1s') + expect(out!.line).toContain('turn 2/5') + expect(out!.line).toContain('next 1s') expect(out!.line).toContain('实现一个非常长') }) diff --git a/apps/cli/src/ui/components/GoalStatusPanel.tsx b/apps/cli/src/ui/components/GoalStatusPanel.tsx index 8cd7dba5d..9cf42b857 100644 --- a/apps/cli/src/ui/components/GoalStatusPanel.tsx +++ b/apps/cli/src/ui/components/GoalStatusPanel.tsx @@ -49,9 +49,9 @@ export function buildGoalStatusLineForTests(args: { const progress = goal.schedule.kind === 'interval' - ? `轮 ${goal.activeRun?.turnCount ?? 0}/${goal.loop.maxIterations}` + ? `turn ${goal.activeRun?.turnCount ?? 0}/${goal.loop.maxIterations}` : goal.schedule.kind === 'once' - ? `轮 ${goal.activeRun?.turnCount ?? 0}` + ? `turn ${goal.activeRun?.turnCount ?? 0}` : '' const countdown = formatCountdown( goal.schedule.kind === 'interval' ? goal.schedule.nextRunAt : null, @@ -60,7 +60,7 @@ export function buildGoalStatusLineForTests(args: { const parts = [ statusLabel(goal), progress, - countdown ? `下轮 ${countdown}` : '', + countdown ? `next ${countdown}` : '', ].filter(Boolean) const suffix = parts.length > 0 ? ` · ${parts.join(' · ')}` : '' const line = `${truncate(goal.objective, Math.max(10, maxWidth - suffix.length))}${suffix}` @@ -89,7 +89,11 @@ export function GoalStatusPanel({ const isRunning = goal!.status === 'running' const glyph = isRunning ? '■' : '⏱' const color = - goal!.status === 'awaiting_approval' ? theme.warning : theme.kode + goal!.status === 'running' + ? theme.warning + : goal!.status === 'awaiting_approval' + ? theme.warning + : theme.kode return ( { expect(output).toContain(firstLogoPrefix) expect(output).not.toContain(productNameFallback) - expect(output).toContain('/help') + expect(output).toContain('/config') expect(output).toContain('MCP Servers') expect(output).toContain('codegraph') expect(output).not.toMatch(/(?:\n\s*){4,}/) @@ -180,7 +180,7 @@ describe('Logo', () => { expect(output).toContain(productNameFallback) expect(output).not.toContain(firstLogoPrefix) - expect(output).toContain('/help') + expect(output).toContain('/config') expect(output).toContain('MCP Servers:') expect(output).toContain('codegraph') expect(output.split(/\r?\n/).filter(Boolean)).toHaveLength(3) diff --git a/apps/cli/src/ui/components/Logo.tsx b/apps/cli/src/ui/components/Logo.tsx index 4ffe2a7da..7fa9f4e17 100644 --- a/apps/cli/src/ui/components/Logo.tsx +++ b/apps/cli/src/ui/components/Logo.tsx @@ -64,8 +64,8 @@ function LogoQuickActions({ columns >= 55 ? commands : columns >= 42 - ? commands.slice(1, 3) - : commands.slice(1, 2) + ? commands.slice(0, 2) + : commands.slice(0, 1) return ( @@ -168,7 +168,7 @@ export function Logo({ )} {showMcpDetails ? ( - + MCP Servers:{' '} {mcpClients.length === 0 ? ( 'none' @@ -176,14 +176,16 @@ export function Logo({ <> {connected.map((c, index) => ( - {index > 0 ? , : null} + {index > 0 ? ( + , + ) : null} {c.name} ))} {failed.map((c, index) => ( {connected.length > 0 || index > 0 ? ( - , + , ) : null} {c.name} @@ -192,7 +194,7 @@ export function Logo({ )} ) : rows >= SHORT_HELP_MIN_ROWS ? ( - + MCP: {connected.length} connected {failed.length > 0 ? `, ${failed.length} failed` : ''} @@ -237,14 +239,17 @@ export function Logo({ {/* MCP Servers section */} - + {isCompact ? `MCP Servers ${separator}` : `── MCP Servers ${separator}`} {mcpClients.length === 0 ? ( - + {isCompact ? 'No servers configured' : 'No servers configured - run: kode mcp add '} @@ -253,14 +258,16 @@ export function Logo({ {connected.map((c, index) => ( - {index > 0 ? , : null} + {index > 0 ? ( + , + ) : null} {c.name} ))} {failed.map((c, index) => ( {connected.length > 0 || index > 0 ? ( - , + , ) : null} {c.name} @@ -269,15 +276,13 @@ export function Logo({ ) : ( <> {connected.map(c => ( - - {c.name} - + + {c.name} ))} {failed.map(c => ( - - {c.name} - + + {c.name} ))} diff --git a/apps/cli/src/ui/components/ModeIndicator.tsx b/apps/cli/src/ui/components/ModeIndicator.tsx index 235cb20bd..83fe23367 100644 --- a/apps/cli/src/ui/components/ModeIndicator.tsx +++ b/apps/cli/src/ui/components/ModeIndicator.tsx @@ -38,7 +38,7 @@ export function ModeIndicator({ {indicator.mainText} {indicator.shortcutHintText ? ( - {indicator.shortcutHintText} + {indicator.shortcutHintText} ) : null} {showTransitionCount && ( @@ -102,7 +102,7 @@ export function CompactModeIndicator() { return ( {indicator.mainText} - {indicator.shortcutHintText} + {indicator.shortcutHintText} ) } diff --git a/apps/cli/src/ui/components/ProjectOnboarding.tsx b/apps/cli/src/ui/components/ProjectOnboarding.tsx index f775a1903..4e70e468c 100644 --- a/apps/cli/src/ui/components/ProjectOnboarding.tsx +++ b/apps/cli/src/ui/components/ProjectOnboarding.tsx @@ -117,6 +117,17 @@ export default function ProjectOnboarding({ ) } + items.unshift( + + + + Run /config to set up + models, permissions, and MCP. + + + , + ) + items.push( diff --git a/apps/cli/src/ui/components/PromptInput/PendingPrompts.test.ts b/apps/cli/src/ui/components/PromptInput/PendingPrompts.test.ts index 86d68aa5f..c5e9b0a1c 100644 --- a/apps/cli/src/ui/components/PromptInput/PendingPrompts.test.ts +++ b/apps/cli/src/ui/components/PromptInput/PendingPrompts.test.ts @@ -15,6 +15,7 @@ describe('__getPendingPromptLinesForTests', () => { maxLinesPerMessage: 2, }) + expect(lines[0]).toBe('Next') expect(lines.some(line => line.includes('›'))).toBe(true) expect(lines.some(line => line.trim() === '…')).toBe(true) }) @@ -26,7 +27,8 @@ describe('__getPendingPromptLinesForTests', () => { maxMessages: 2, }) - expect(lines[0]).toContain('earlier') + expect(lines[0]).toContain('Next') + expect(lines.some(line => line.includes('earlier'))).toBe(true) expect(lines.join('\n')).toContain('c') expect(lines.join('\n')).toContain('d') expect(lines.join('\n')).not.toContain('› a') diff --git a/apps/cli/src/ui/components/PromptInput/PendingPrompts.tsx b/apps/cli/src/ui/components/PromptInput/PendingPrompts.tsx index 8ae37a2e1..f1a9c3cab 100644 --- a/apps/cli/src/ui/components/PromptInput/PendingPrompts.tsx +++ b/apps/cli/src/ui/components/PromptInput/PendingPrompts.tsx @@ -8,6 +8,8 @@ const FIRST_LINE_PREFIX = ' › ' const WRAPPED_LINE_PREFIX = ' ' const MORE_PENDING_PREFIX = ' … ' const ELLIPSIS_LINE = ' …' +export const PENDING_PROMPTS_HEADER = 'Next · sends after this turn' +export const PENDING_PROMPTS_HEADER_SHORT = 'Next' export function __getPendingPromptLinesForTests(args: { pendingPrompts: string[] @@ -59,6 +61,11 @@ export function __getPendingPromptLinesForTests(args: { } } + if (lines.length === 0) return [] + + lines.unshift( + safeWidth >= 36 ? PENDING_PROMPTS_HEADER : PENDING_PROMPTS_HEADER_SHORT, + ) return lines } diff --git a/apps/cli/src/ui/components/PromptInput/PromptInput.tsx b/apps/cli/src/ui/components/PromptInput/PromptInput.tsx index 69c999a65..7998e287f 100644 --- a/apps/cli/src/ui/components/PromptInput/PromptInput.tsx +++ b/apps/cli/src/ui/components/PromptInput/PromptInput.tsx @@ -56,13 +56,19 @@ import { PromptInputView } from './PromptInputView' import { useExternalEdit } from './useExternalEdit' import { useQuickModelSwitch } from './useQuickModelSwitch' import { getKodeAgentSessionId } from '#protocol/utils/kodeAgentSessionId' -import { buildPromptInputStatusLine } from './inputModeDisplay' +import { + buildPromptInputStatusLine, + formatCancelledFollowUpsMessage, + PROMPT_NOTHING_TO_STASH_MESSAGE, + PROMPT_RESTORED_MESSAGE, + PROMPT_STASHED_MESSAGE, +} from './inputModeDisplay' import { useThrottledTokenUsage } from './useThrottledTokenUsage' import { useCliExit } from '#ui-ink/hooks/useCliExit' import { buildPromptStatusLineInput } from './statusLineModel' import { useThrottledStatusLineUsage } from './useThrottledStatusLineUsage' import { - getPromptModeForTypedPrefix, + applyTypedPromptModePrefix, shouldEmptyPromptModeExitToPrompt, } from './promptModeSpecs' @@ -190,6 +196,34 @@ export function PromptInput({ prev.show === show && prev.text === text ? prev : { show, text }, ) }, []) + const timedInlineMessageTimeoutRef = useRef | null>(null) + const showTimedInlineMessage = useCallback( + (text: string, delayMs = 4000) => { + if (timedInlineMessageTimeoutRef.current) { + clearTimeout(timedInlineMessageTimeoutRef.current) + timedInlineMessageTimeoutRef.current = null + } + handleInlineMessage(true, text) + timedInlineMessageTimeoutRef.current = setTimeout(() => { + setMessage(prev => { + if (!prev.show || prev.text !== text) return prev + return { show: false } + }) + timedInlineMessageTimeoutRef.current = null + }, delayMs) + }, + [handleInlineMessage], + ) + useEffect(() => { + return () => { + if (timedInlineMessageTimeoutRef.current) { + clearTimeout(timedInlineMessageTimeoutRef.current) + timedInlineMessageTimeoutRef.current = null + } + } + }, []) const handleClearInput = useDoublePress(setClearInputPending, () => { clearPastes() onInputChange('') @@ -235,9 +269,11 @@ export function PromptInput({ (value: string) => { onHistoryUserInputRef.current() - const nextMode = getPromptModeForTypedPrefix({ mode, value }) - if (nextMode) { - onModeChange(nextMode) + const next = applyTypedPromptModePrefix({ mode, value }) + if (next) { + onModeChange(next.mode) + onInputChange(next.value) + setCursorOffset(next.value.length) return } @@ -260,9 +296,7 @@ export function PromptInput({ provider: current.provider, contextLength: current.contextLength, currentTokens: tokenUsage, - // Show the effective reasoning effort; reasoning models default to - // a balanced level when the profile does not pin one. - reasoningEffort: current.reasoningEffort?.trim() || 'medium', + reasoningEffort: current.reasoningEffort?.trim() || undefined, } : null }, [submitCount, tokenUsage, uiRefreshCounter]) @@ -327,14 +361,17 @@ export function PromptInput({ queuedPromptCount: queuedPrompts.length, editorMode, vimMode, + stashRestorable: promptStash !== null && input.trim() === '', }) }, [ currentMode, editorMode, + input, isLoading, mode, modeCycleShortcut.displayText, pendingPrompts.length, + promptStash, queuedPrompts.length, vimMode, ]) @@ -452,12 +489,13 @@ export function PromptInput({ if (isDisabled) return undefined if (key.meta && key.upArrow && !key.shift && !key.ctrl) { - const latest = - queuedPrompts.length > 0 - ? queuedPrompts.reduce((best, item) => - item.seq > best.seq ? item : best, - ) - : null + const latest = [ + ...queuedPrompts, + ...pendingPrompts, + ].reduce( + (best, item) => (!best || item.seq > best.seq ? item : best), + null, + ) if (!latest) return undefined let draftForQueue: QueuedPrompt | null = null @@ -483,6 +521,7 @@ export function PromptInput({ setQueuedPrompts(prev => [...prev, draftForQueue]) } setQueuedPrompts(prev => prev.filter(item => item !== latest)) + setPendingPrompts(prev => prev.filter(item => item !== latest)) clearPastes() onModeChange(latest.mode) onInputChange(latest.input) @@ -1007,12 +1046,11 @@ export function PromptInput({ if (isDisabled) return if (!value.trim()) return - // Enter with an active completion panel submits the completed word instead - // of the raw prefix: "/hel" + Enter must run /help, not silently become a - // chat message. Plain Enter still sends in a single keystroke. + // Slash-command Enter still inserts then sends (`/hel` → `/help`). + // File and @ completions are accepted on Enter without submitting. if ( completionVisible && - activeContext && + activeContext?.type === 'command' && suggestions[selectedIndex] !== undefined ) { const completed = buildCompletionInsert({ @@ -1109,6 +1147,7 @@ export function PromptInput({ useEffect(() => { if (lastCancelRequestKeyRef.current !== cancelRequestKey) { lastCancelRequestKeyRef.current = cancelRequestKey + const discardedCount = pendingPrompts.length + queuedPrompts.length setPendingPrompts(prev => { if (prev.length === 0) return prev for (const prompt of prev) { @@ -1124,6 +1163,9 @@ export function PromptInput({ return [] }) setIsQueueDrainInFlight(false) + if (discardedCount > 0) { + showTimedInlineMessage(formatCancelledFollowUpsMessage(discardedCount)) + } return } @@ -1214,6 +1256,7 @@ export function PromptInput({ readFileTimestamps, reportMissingImageData, handleInlineMessage, + showTimedInlineMessage, isQueueDrainInFlight, setAbortController, setCurrentPwd, @@ -1255,6 +1298,7 @@ export function PromptInput({ setPastedImages(promptStash.pastedImages) setCursorOffset(promptStash.cursorOffset) setPromptStash(null) + showTimedInlineMessage(PROMPT_RESTORED_MESSAGE) return true } @@ -1281,9 +1325,11 @@ export function PromptInput({ clearPastes() onInputChange('') setCursorOffset(0) + showTimedInlineMessage(PROMPT_STASHED_MESSAGE) return true } + showTimedInlineMessage(PROMPT_NOTHING_TO_STASH_MESSAGE, 2500) return true } diff --git a/apps/cli/src/ui/components/PromptInput/PromptInputCompletionPanel.test.ts b/apps/cli/src/ui/components/PromptInput/PromptInputCompletionPanel.test.ts index 8187308ba..0c1de7bca 100644 --- a/apps/cli/src/ui/components/PromptInput/PromptInputCompletionPanel.test.ts +++ b/apps/cli/src/ui/components/PromptInput/PromptInputCompletionPanel.test.ts @@ -8,6 +8,8 @@ import { getTheme } from '#core/utils/theme' import { __areSuggestionItemPropsEqualForTests, __areHelpTextPropsEqualForTests, + __completionHelpKindForTests, + __completionKeybindingHelpForTests, __getSuggestionWindowForTests, PromptInputCompletionPanel, } from './PromptInputCompletionPanel' @@ -70,6 +72,44 @@ function createHarness( return harness } +describe('completion help copy', () => { + it('tells the user Enter sends a slash command', () => { + expect( + __completionHelpKindForTests({ + emptyDirMessage: '', + selectedSuggestion: { type: 'command', value: 'help' }, + }), + ).toBe('command') + expect(__completionKeybindingHelpForTests('command')).toBe( + 'Tab accept • Enter send • ↑↓ navigate • Esc close', + ) + }) + + it('treats ask suggestions as mentions, not paths', () => { + expect( + __completionHelpKindForTests({ + emptyDirMessage: '', + selectedSuggestion: { type: 'ask', value: 'sonnet' }, + }), + ).toBe('mention') + expect(__completionKeybindingHelpForTests('mention')).toContain( + 'insert mention', + ) + }) + + it('describes directory follow-up without claiming Enter opens it', () => { + expect( + __completionHelpKindForTests({ + emptyDirMessage: '', + selectedSuggestion: { type: 'file', value: 'src/' }, + }), + ).toBe('directory') + expect(__completionKeybindingHelpForTests('directory')).toBe( + 'Enter send • → open folder • ↑↓ navigate • Tab cycle • Esc close', + ) + }) +}) + describe('__getSuggestionWindowForTests', () => { it('rerenders selected suggestions when their theme color changes', () => { const theme = getTheme() diff --git a/apps/cli/src/ui/components/PromptInput/PromptInputCompletionPanel.tsx b/apps/cli/src/ui/components/PromptInput/PromptInputCompletionPanel.tsx index 6a035f792..4a3d4b0b3 100644 --- a/apps/cli/src/ui/components/PromptInput/PromptInputCompletionPanel.tsx +++ b/apps/cli/src/ui/components/PromptInput/PromptInputCompletionPanel.tsx @@ -89,24 +89,55 @@ export function __areHelpTextPropsEqualForTests( ) } +export type CompletionHelpKind = + 'empty' | 'none' | 'command' | 'directory' | 'mention' | 'file' + +export function __completionHelpKindForTests(args: { + emptyDirMessage: string + selectedSuggestion?: { type: string; value: string } +}): CompletionHelpKind { + if (args.emptyDirMessage) return 'empty' + if (!args.selectedSuggestion) return 'none' + if (args.selectedSuggestion.type === 'command') return 'command' + if (args.selectedSuggestion.value.endsWith('/')) return 'directory' + if ( + args.selectedSuggestion.type === 'agent' || + args.selectedSuggestion.type === 'ask' + ) { + return 'mention' + } + return 'file' +} + +export function __completionKeybindingHelpForTests( + kind: CompletionHelpKind, +): string { + switch (kind) { + case 'empty': + return '' + case 'none': + return '↑↓ navigate • Tab cycle • Esc close' + case 'command': + return 'Tab accept • Enter send • ↑↓ navigate • Esc close' + case 'directory': + return 'Enter send • → open folder • ↑↓ navigate • Tab cycle • Esc close' + case 'mention': + return 'Enter/→ insert mention • ↑↓ navigate • Tab cycle • Esc close' + case 'file': + return 'Enter send • → insert path • ↑↓ navigate • Tab cycle • Esc close' + } +} + // 使用 React.memo 优化帮助文本组件 const HelpText = React.memo( ({ emptyDirMessage, selectedSuggestion, maxWidth, theme }: HelpTextProps) => { const getHelpMessage = () => { - if (emptyDirMessage) return emptyDirMessage - if (!selectedSuggestion) { - return '↑↓ navigate • → accept • Tab cycle • Esc close' - } - if (selectedSuggestion.type === 'command') { - return 'Tab accept • ↑↓ navigate • → accept • Esc close' - } - if (selectedSuggestion.value.endsWith('/')) { - return '→ enter directory • ↑↓ navigate • Tab cycle • Esc close' - } - if (selectedSuggestion.type === 'agent') { - return '→ select agent • ↑↓ navigate • Tab cycle • Esc close' - } - return '→ insert reference • ↑↓ navigate • Tab cycle • Esc close' + const kind = __completionHelpKindForTests({ + emptyDirMessage, + selectedSuggestion, + }) + if (kind === 'empty') return emptyDirMessage + return __completionKeybindingHelpForTests(kind) } const moreCount = @@ -136,16 +167,15 @@ const HelpText = React.memo( const moreHint = moreCount > 0 ? ` · ${moreCount} more, type to filter` : '' return ( - - {`${limited}${moreHint} • Tab accept`} + + {`${limited}${moreHint} • Tab accept • Enter send`} ) } return ( {getHelpMessage()} diff --git a/apps/cli/src/ui/components/PromptInput/PromptInputView.test.tsx b/apps/cli/src/ui/components/PromptInput/PromptInputView.test.tsx index 305d68875..091039007 100644 --- a/apps/cli/src/ui/components/PromptInput/PromptInputView.test.tsx +++ b/apps/cli/src/ui/components/PromptInput/PromptInputView.test.tsx @@ -89,6 +89,7 @@ function renderPromptInputView(args: { terminalRows?: number terminalColumns?: number suppressStatusLine?: boolean + emptyDirMessage?: string }) { const tokenUsage = args.tokenUsage ?? 0 const terminalRows = args.terminalRows ?? 24 @@ -120,7 +121,7 @@ function renderPromptInputView(args: { historyIndex={0} suggestions={[]} selectedIndex={0} - emptyDirMessage="" + emptyDirMessage={args.emptyDirMessage ?? ''} handleHistoryUp={() => {}} handleHistoryDown={() => {}} resetHistory={() => {}} @@ -170,6 +171,7 @@ describe('PromptInputView status line layout', () => { expect(output).toContain('mimo-v2.5-pro \u00b7 0/1.0M') expect(output).toContain('Chat') expect(output).not.toContain('[custom-openai]') + expect(output).not.toContain('(medium)') expect(output).not.toContain('/bash command') expect(output).not.toContain('/note note') @@ -210,6 +212,22 @@ describe('PromptInputView status line layout', () => { expect(output).not.toContain('0 / 1.0M') }) + test('shows an empty-directory hint on the status line after completion closes', async () => { + const harness = createHarness( + renderPromptInputView({ + customStatusLineActive: false, + statusLine: 'Chat', + emptyDirMessage: 'No files in docs/', + }), + ) + + await harness.wait(20) + const output = harness.getOutput() + + expect(output).toContain('No files in docs/') + expect(output).not.toContain('Chat') + }) + test('lets priority messages use the full status line', async () => { const pasteGuardMessage = 'Paste detected. Press Enter again to send.' const harness = createHarness( diff --git a/apps/cli/src/ui/components/PromptInput/PromptInputView.tsx b/apps/cli/src/ui/components/PromptInput/PromptInputView.tsx index e77ef2cf2..e9abcacbb 100644 --- a/apps/cli/src/ui/components/PromptInput/PromptInputView.tsx +++ b/apps/cli/src/ui/components/PromptInput/PromptInputView.tsx @@ -335,6 +335,10 @@ export function PromptInputView({ > {toastMessage.text} + ) : emptyDirMessage ? ( + + {emptyDirMessage} + ) : statusLine ? ( {statusLine} diff --git a/apps/cli/src/ui/components/PromptInput/QueuedPrompts.test.ts b/apps/cli/src/ui/components/PromptInput/QueuedPrompts.test.ts index 74fcdbbb8..029ae815c 100644 --- a/apps/cli/src/ui/components/PromptInput/QueuedPrompts.test.ts +++ b/apps/cli/src/ui/components/PromptInput/QueuedPrompts.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it } from 'bun:test' import { __getQueuedPromptLinesForTests } from './QueuedPrompts' describe('__getQueuedPromptLinesForTests', () => { + it('labels the queue with the edit affordance on a wide row', () => { + const lines = __getQueuedPromptLinesForTests({ + queuedPrompts: ['follow up'], + width: 80, + }) + + expect(lines[0]).toBe('Queued · Alt+Up edit') + expect(lines[1]).toContain('follow up') + }) + it('returns empty when there are no queued prompts', () => { expect( __getQueuedPromptLinesForTests({ queuedPrompts: [], width: 80 }), @@ -15,6 +25,7 @@ describe('__getQueuedPromptLinesForTests', () => { maxLinesPerMessage: 2, }) + expect(lines[0]).toBe('Queued') expect(lines.some(line => line.includes('↳'))).toBe(true) expect(lines.some(line => line.trim() === '…')).toBe(true) }) @@ -26,7 +37,8 @@ describe('__getQueuedPromptLinesForTests', () => { maxMessages: 2, }) - expect(lines[0]).toContain('earlier') + expect(lines[0]).toContain('Queued') + expect(lines.some(line => line.includes('earlier'))).toBe(true) expect(lines.join('\n')).toContain('c') expect(lines.join('\n')).toContain('d') expect(lines.join('\n')).not.toContain('↳ a') diff --git a/apps/cli/src/ui/components/PromptInput/QueuedPrompts.tsx b/apps/cli/src/ui/components/PromptInput/QueuedPrompts.tsx index bee670beb..620999e79 100644 --- a/apps/cli/src/ui/components/PromptInput/QueuedPrompts.tsx +++ b/apps/cli/src/ui/components/PromptInput/QueuedPrompts.tsx @@ -8,6 +8,8 @@ const FIRST_LINE_PREFIX = ' ↳ ' const WRAPPED_LINE_PREFIX = ' ' const MORE_QUEUED_PREFIX = ' … ' const ELLIPSIS_LINE = ' …' +export const QUEUED_PROMPTS_HEADER = 'Queued · Alt+Up edit' +export const QUEUED_PROMPTS_HEADER_SHORT = 'Queued' export function __getQueuedPromptLinesForTests(args: { queuedPrompts: string[] @@ -59,6 +61,11 @@ export function __getQueuedPromptLinesForTests(args: { } } + if (lines.length === 0) return [] + + lines.unshift( + safeWidth >= 28 ? QUEUED_PROMPTS_HEADER : QUEUED_PROMPTS_HEADER_SHORT, + ) return lines } diff --git a/apps/cli/src/ui/components/PromptInput/inputModeDisplay.ts b/apps/cli/src/ui/components/PromptInput/inputModeDisplay.ts index 7d4209866..a04f834eb 100644 --- a/apps/cli/src/ui/components/PromptInput/inputModeDisplay.ts +++ b/apps/cli/src/ui/components/PromptInput/inputModeDisplay.ts @@ -20,6 +20,16 @@ export function getInputModeDisplay(mode: PromptMode): InputModeDisplay { } } +export const PROMPT_STASHED_MESSAGE = 'Prompt stashed · Ctrl+S to restore' +export const PROMPT_RESTORED_MESSAGE = 'Prompt restored' +export const PROMPT_NOTHING_TO_STASH_MESSAGE = 'Nothing to stash' + +export function formatCancelledFollowUpsMessage(count: number): string { + if (count <= 0) return 'Cancelled' + if (count === 1) return 'Cancelled · discarded 1 follow-up' + return `Cancelled · discarded ${count} follow-ups` +} + export function buildPromptInputStatusLine(args: { mode: PromptMode permissionMode: PermissionMode @@ -29,6 +39,7 @@ export function buildPromptInputStatusLine(args: { queuedPromptCount: number editorMode?: string vimMode?: 'INSERT' | 'NORMAL' + stashRestorable?: boolean }): string { const inputMode = getInputModeDisplay(args.mode) const parts = [ @@ -53,8 +64,15 @@ export function buildPromptInputStatusLine(args: { if (args.queuedPromptCount > 0) { parts.push(`queued ${args.queuedPromptCount}`) + } + + if (args.pendingPromptCount > 0 || args.queuedPromptCount > 0) { parts.push('Alt+Up edit') } + if (args.stashRestorable) { + parts.push('Ctrl+S restore') + } + return parts.join(' \u00b7 ') } diff --git a/apps/cli/src/ui/components/PromptInput/promptModeSpecs.ts b/apps/cli/src/ui/components/PromptInput/promptModeSpecs.ts index ea4627a67..22763e739 100644 --- a/apps/cli/src/ui/components/PromptInput/promptModeSpecs.ts +++ b/apps/cli/src/ui/components/PromptInput/promptModeSpecs.ts @@ -82,6 +82,22 @@ export function getPromptModeForTypedPrefix(args: { return null } +export function applyTypedPromptModePrefix(args: { + mode: PromptMode + value: string +}): { mode: PromptMode; value: string } | null { + const nextMode = getPromptModeForTypedPrefix(args) + if (!nextMode) return null + + const prefix = getPromptModeSpec(nextMode).typedPrefix ?? '' + const remainder = + prefix && args.value.startsWith(prefix) + ? args.value.slice(prefix.length) + : args.value + + return { mode: nextMode, value: remainder } +} + export function getPromptModePrefix(args: { mode: PromptMode theme: Theme diff --git a/apps/cli/src/ui/components/RequestStatusIndicator.test.tsx b/apps/cli/src/ui/components/RequestStatusIndicator.test.tsx index 030c99057..45942617f 100644 --- a/apps/cli/src/ui/components/RequestStatusIndicator.test.tsx +++ b/apps/cli/src/ui/components/RequestStatusIndicator.test.tsx @@ -19,7 +19,7 @@ describe('RequestStatusIndicator', () => { test('identifies an extended wait for the first model response', () => { expect( __getRequestStatusLabelForTests({ kind: 'waiting', updatedAt: 0 }, 15), - ).toBe('Waiting for model response · still waiting') + ).toBe('Waiting for model response') }) test('shows the active tool instead of hiding work in progress', () => { diff --git a/apps/cli/src/ui/components/RequestStatusIndicator.tsx b/apps/cli/src/ui/components/RequestStatusIndicator.tsx index d0a22deb9..55f42aff2 100644 --- a/apps/cli/src/ui/components/RequestStatusIndicator.tsx +++ b/apps/cli/src/ui/components/RequestStatusIndicator.tsx @@ -9,6 +9,7 @@ import { getRequestStatusTiming, getRequestStatusTokenDisplay, REQUEST_STATUS_ESC_CANCEL_HINT, + shouldShowRequestStatusPhase, subscribeRequestStatus, type RequestStatus, } from '#core/utils/requestStatus' @@ -78,8 +79,10 @@ export function RequestStatusIndicator({ )} - {' '} - · {getRequestStatusPhaseLabel(status, now)} · total{' '} + {shouldShowRequestStatusPhase(status, now) + ? ` · ${getRequestStatusPhaseLabel(status, now)}` + : ''} + {' · total '} {formatRequestStatusDuration( Math.floor(timing.requestDurationMs / 1000), )}{' '} diff --git a/apps/cli/src/ui/components/RunningTasksPanel.tsx b/apps/cli/src/ui/components/RunningTasksPanel.tsx index 754ca1ff4..aef734631 100644 --- a/apps/cli/src/ui/components/RunningTasksPanel.tsx +++ b/apps/cli/src/ui/components/RunningTasksPanel.tsx @@ -117,12 +117,16 @@ export const RunningTasksPanel = React.memo(function RunningTasksPanel({ width="100%" > - Local Tasks + Local agents & shells /tasks {rows.map(row => ( - + {statusGlyph(row.status)}{' '} diff --git a/apps/cli/src/ui/components/TextInput.tsx b/apps/cli/src/ui/components/TextInput.tsx index 841bba810..9f53834c6 100644 --- a/apps/cli/src/ui/components/TextInput.tsx +++ b/apps/cli/src/ui/components/TextInput.tsx @@ -335,17 +335,20 @@ export default function TextInput({ return } - // Special handling for backspace or delete - if ( - key.backspace || - key.delete || - input === '\b' || - isBackspaceChar(input) - ) { - // Ensure backspace is handled directly + if (key.delete && !key.backspace && !isBackspaceChar(input)) { + onInput(input, { + ...key, + delete: true, + backspace: false, + }) + return + } + + if (key.backspace || input === '\b' || isBackspaceChar(input)) { onInput(input, { ...key, backspace: true, + delete: false, }) return } diff --git a/apps/cli/src/ui/components/animationLifecycle.test.tsx b/apps/cli/src/ui/components/animationLifecycle.test.tsx index dcc2f97b4..8691f3a80 100644 --- a/apps/cli/src/ui/components/animationLifecycle.test.tsx +++ b/apps/cli/src/ui/components/animationLifecycle.test.tsx @@ -286,7 +286,7 @@ describe('animation lifecycle', () => { expect(output).toContain('Writing response') expect(output).toContain('↓ 13k') - expect(output).toContain('(Esc cancel)') + expect(output).toContain('(Esc to cancel)') expect(setIntervalSpy).toHaveBeenCalled() }) }) diff --git a/apps/cli/src/ui/components/permissions/FallbackPermissionRequest.tsx b/apps/cli/src/ui/components/permissions/FallbackPermissionRequest.tsx index ac4bcd65a..cfe318d18 100644 --- a/apps/cli/src/ui/components/permissions/FallbackPermissionRequest.tsx +++ b/apps/cli/src/ui/components/permissions/FallbackPermissionRequest.tsx @@ -22,6 +22,7 @@ import { import { ScreenFrame } from '#ui-ink/primitives/layout/ScreenFrame' import { useScreenLayout } from '#ui-ink/primitives/layout/useScreenLayout' import { PermissionRequestDetails } from './PermissionRequestDetails' +import { getPermissionDenyOptionLabel } from './toolUseOptions' import { defaultPermissionFocusValue, permissionSelectFocusScope, @@ -105,7 +106,7 @@ export function FallbackPermissionRequest({ value: 'yes-dont-ask-again', }, { - label: `No, and provide instructions (${chalk.bold.hex(getTheme().warning)('esc')})`, + label: getPermissionDenyOptionLabel(), value: 'no', }, ]} diff --git a/apps/cli/src/ui/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx b/apps/cli/src/ui/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx index f0d1f863d..ab5337e70 100644 --- a/apps/cli/src/ui/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx +++ b/apps/cli/src/ui/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx @@ -24,6 +24,7 @@ import { PermissionRequestDetails } from '#ui-ink/components/permissions/Permiss import { applyToolPermissionUpdatesToLiveToolUseContext } from '../liveToolPermissionContext' import { computeAvailableColumns } from '#ui-ink/primitives/layout/viewportColumns' import { permissionSelectFocusScope } from '#ui-ink/components/permissions/permissionFocusScope' +import { getPermissionDenyOptionLabel } from '#ui-ink/components/permissions/toolUseOptions' function getOptions(args: { path: string @@ -40,7 +41,7 @@ function getOptions(args: { value: 'yes', }, { - label: `Deny and provide instructions (${chalk.bold.hex(getTheme().warning)('esc')})`, + label: getPermissionDenyOptionLabel(), value: 'no', }, ] diff --git a/apps/cli/src/ui/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx b/apps/cli/src/ui/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx index 7309866a9..e5bf9a8a2 100644 --- a/apps/cli/src/ui/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx +++ b/apps/cli/src/ui/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx @@ -22,6 +22,7 @@ import { useKeypress } from '#ui-ink/hooks/useKeypress' import { ScreenFrame } from '#ui-ink/primitives/layout/ScreenFrame' import { useScreenLayout } from '#ui-ink/primitives/layout/useScreenLayout' import { PermissionRequestDetails } from '#ui-ink/components/permissions/PermissionRequestDetails' +import { getPermissionDenyOptionLabel } from '#ui-ink/components/permissions/toolUseOptions' import { applyToolPermissionUpdatesToLiveToolUseContext } from '../liveToolPermissionContext' import { computeAvailableColumns } from '#ui-ink/primitives/layout/viewportColumns' import { permissionSelectFocusScope } from '#ui-ink/components/permissions/permissionFocusScope' @@ -192,7 +193,7 @@ export function FileWritePermissionRequest({ ] : []), { - label: `Deny and provide instructions (${chalk.bold.hex(getTheme().warning)('esc')})`, + label: getPermissionDenyOptionLabel(), value: 'no', }, ]} diff --git a/apps/cli/src/ui/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx b/apps/cli/src/ui/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx index 403e5a980..4f51636a0 100644 --- a/apps/cli/src/ui/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx +++ b/apps/cli/src/ui/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx @@ -32,6 +32,7 @@ import { useKeypress } from '#ui-ink/hooks/useKeypress' import { ScreenFrame } from '#ui-ink/primitives/layout/ScreenFrame' import { useScreenLayout } from '#ui-ink/primitives/layout/useScreenLayout' import { PermissionRequestDetails } from '#ui-ink/components/permissions/PermissionRequestDetails' +import { getPermissionDenyOptionLabel } from '#ui-ink/components/permissions/toolUseOptions' import { applyToolPermissionUpdatesToLiveToolUseContext } from '../liveToolPermissionContext' import { permissionSelectFocusScope } from '#ui-ink/components/permissions/permissionFocusScope' @@ -298,7 +299,7 @@ function FilesystemPermissionRequestImpl({ hasSessionSuggestion, ), { - label: `Deny and provide instructions (${chalk.bold.hex(getTheme().warning)('esc')})`, + label: getPermissionDenyOptionLabel(), value: 'no', }, ]} diff --git a/apps/cli/src/ui/components/permissions/PermissionRequest.tsx b/apps/cli/src/ui/components/permissions/PermissionRequest.tsx index 7a4ef2aed..c40289d2e 100644 --- a/apps/cli/src/ui/components/permissions/PermissionRequest.tsx +++ b/apps/cli/src/ui/components/permissions/PermissionRequest.tsx @@ -30,6 +30,7 @@ import { AskUserQuestionTool } from '#tools/tools/interaction/AskUserQuestionToo import { AskUserQuestionPermissionRequest } from './AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest' import type { ToolPermissionContextUpdate } from '#core/types/toolPermissionContext' import { useKeypress } from '#ui-ink/hooks/useKeypress' +import { KEYPRESS_PRIORITY } from '#ui-ink/constants/keypressPriority' import { Box, Text } from 'ink' import { getTheme } from '#core/utils/theme' @@ -181,8 +182,9 @@ export function PermissionRequest({ } return undefined }, - // Let tool-specific permission UIs intercept Esc first (e.g. WebFetch logging). - { priority: -10 }, + // Above REPL cancel (51) so Esc denies this tool instead of aborting the + // turn. Tool-specific handlers that must run first should use INLINE_TOOL+1. + { priority: KEYPRESS_PRIORITY.INLINE_TOOL }, ) const toolName = diff --git a/apps/cli/src/ui/components/permissions/PermissionRequestDetails.test.ts b/apps/cli/src/ui/components/permissions/PermissionRequestDetails.test.ts index 4d1c31e3e..9f6cd2ac9 100644 --- a/apps/cli/src/ui/components/permissions/PermissionRequestDetails.test.ts +++ b/apps/cli/src/ui/components/permissions/PermissionRequestDetails.test.ts @@ -14,12 +14,23 @@ describe('PermissionRequestDetails helpers', () => { } as any) expect(lines).toEqual([ - 'Agent: main · Mode: plan', + 'Agent: main · Mode: Plan first', 'Reason: No allow rule matched (outside working directories)', 'Path: /tmp/example.txt', ]) }) + test('uses the readable permission policy name', () => { + const lines = __buildPermissionRequestDetailsLinesForTests({ + toolUseContext: { + agentId: 'main', + options: { toolPermissionContext: { mode: 'acceptEdits' } }, + }, + } as any) + + expect(lines).toEqual(['Agent: main · Mode: Edit']) + }) + test('returns empty list when nothing is available', () => { const lines = __buildPermissionRequestDetailsLinesForTests({ toolUseContext: { agentId: '' }, diff --git a/apps/cli/src/ui/components/permissions/PermissionRequestDetails.tsx b/apps/cli/src/ui/components/permissions/PermissionRequestDetails.tsx index 419b1988f..78d1b0979 100644 --- a/apps/cli/src/ui/components/permissions/PermissionRequestDetails.tsx +++ b/apps/cli/src/ui/components/permissions/PermissionRequestDetails.tsx @@ -1,18 +1,30 @@ import React, { useMemo } from 'react' import { Box, Text } from 'ink' +import type { PermissionMode } from '#core/types/PermissionMode' +import { getTheme } from '#core/utils/theme' +import { getPermissionModeStatusLabel } from '#ui-ink/utils/permissionModeDisplay' import type { ToolUseConfirm } from './PermissionRequest' +const PERMISSION_MODES = new Set([ + 'cautious', + 'acceptEdits', + 'plan', +]) + function formatAgentLabel(agentId: string): string { if (agentId === 'main') return 'Agent: main' return `Agent: ${agentId}` } function formatModeLabel(mode: unknown): string | null { - if (mode !== 'plan' && mode !== 'acceptEdits' && mode !== 'cautious') { + if ( + typeof mode !== 'string' || + !PERMISSION_MODES.has(mode as PermissionMode) + ) { return null } - return `Mode: ${mode}` + return `Mode: ${getPermissionModeStatusLabel(mode as PermissionMode)}` } export function __buildPermissionRequestDetailsLinesForTests( @@ -59,10 +71,11 @@ export function PermissionRequestDetails({ ) if (lines.length === 0) return null + const theme = getTheme() return ( {lines.map((line, idx) => ( - + {line} ))} diff --git a/apps/cli/src/ui/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx b/apps/cli/src/ui/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx index 38ee88d04..d4131be13 100644 --- a/apps/cli/src/ui/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx +++ b/apps/cli/src/ui/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx @@ -15,6 +15,7 @@ import { env } from '#core/utils/env' import { ScreenFrame } from '#ui-ink/primitives/layout/ScreenFrame' import { useScreenLayout } from '#ui-ink/primitives/layout/useScreenLayout' import { PermissionRequestDetails } from '#ui-ink/components/permissions/PermissionRequestDetails' +import { getPermissionDenyOptionLabel } from '#ui-ink/components/permissions/toolUseOptions' import { defaultPermissionFocusValue, permissionSelectFocusScope, @@ -78,7 +79,7 @@ export function SkillPermissionRequest({ value: 'yes-exact', }, { - label: `Deny and provide instructions (${chalk.bold.hex(getTheme().warning)('esc')})`, + label: getPermissionDenyOptionLabel(), value: 'no', }, ]} diff --git a/apps/cli/src/ui/components/permissions/SlashCommandPermissionRequest/SlashCommandPermissionRequest.tsx b/apps/cli/src/ui/components/permissions/SlashCommandPermissionRequest/SlashCommandPermissionRequest.tsx index 3a6c886e6..7c9fe502f 100644 --- a/apps/cli/src/ui/components/permissions/SlashCommandPermissionRequest/SlashCommandPermissionRequest.tsx +++ b/apps/cli/src/ui/components/permissions/SlashCommandPermissionRequest/SlashCommandPermissionRequest.tsx @@ -18,6 +18,7 @@ import { env } from '#core/utils/env' import { ScreenFrame } from '#ui-ink/primitives/layout/ScreenFrame' import { useScreenLayout } from '#ui-ink/primitives/layout/useScreenLayout' import { PermissionRequestDetails } from '#ui-ink/components/permissions/PermissionRequestDetails' +import { getPermissionDenyOptionLabel } from '#ui-ink/components/permissions/toolUseOptions' import { defaultPermissionFocusValue, permissionSelectFocusScope, @@ -101,7 +102,7 @@ export function SlashCommandPermissionRequest({ ] : []), { - label: `Deny and provide instructions (${chalk.bold.hex(getTheme().warning)('esc')})`, + label: getPermissionDenyOptionLabel(), value: 'no', }, ]} diff --git a/apps/cli/src/ui/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx b/apps/cli/src/ui/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx index e0458aa80..2e4b5107f 100644 --- a/apps/cli/src/ui/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx +++ b/apps/cli/src/ui/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx @@ -15,6 +15,7 @@ import { useKeypress } from '#ui-ink/hooks/useKeypress' import { ScreenFrame } from '#ui-ink/primitives/layout/ScreenFrame' import { useScreenLayout } from '#ui-ink/primitives/layout/useScreenLayout' import { PermissionRequestDetails } from '#ui-ink/components/permissions/PermissionRequestDetails' +import { getPermissionDenyOptionLabel } from '#ui-ink/components/permissions/toolUseOptions' import { defaultPermissionFocusValue, permissionSelectFocusScope, @@ -104,7 +105,7 @@ export function WebFetchPermissionRequest({ ] : []), { - label: `Deny and provide instructions ${chalk.bold('(esc)')}`, + label: getPermissionDenyOptionLabel(), value: 'no', }, ]} diff --git a/apps/cli/src/ui/components/permissions/toolUseOptions.ts b/apps/cli/src/ui/components/permissions/toolUseOptions.ts index e1a063c22..2e7d9a457 100644 --- a/apps/cli/src/ui/components/permissions/toolUseOptions.ts +++ b/apps/cli/src/ui/components/permissions/toolUseOptions.ts @@ -26,6 +26,10 @@ const SHELL_KEYWORD_PREFIXES = new Set([ 'done', ]) +export function getPermissionDenyOptionLabel(): string { + return `Deny (${chalk.bold.hex(getTheme().warning)('Esc')})` +} + /** * Generates options for the tool use confirmation dialog */ @@ -74,7 +78,7 @@ export function toolUseOptions({ }, ...dontShowAgainOptions, { - label: `Deny and provide instructions (${chalk.bold.hex(getTheme().warning)('esc')})`, + label: getPermissionDenyOptionLabel(), value: 'no', }, ] diff --git a/apps/cli/src/ui/hooks/useCancelRequest.test.ts b/apps/cli/src/ui/hooks/useCancelRequest.test.ts index ac66638ff..ba91af12e 100644 --- a/apps/cli/src/ui/hooks/useCancelRequest.test.ts +++ b/apps/cli/src/ui/hooks/useCancelRequest.test.ts @@ -50,4 +50,28 @@ describe('request cancellation', () => { }), ).toBe(false) }) + + test('leaves Escape available to deny a permission dialog', () => { + expect( + shouldHandleCancelRequest({ + wantsCancel: true, + isLoading: true, + isMessageSelectorVisible: false, + isPermissionDialogVisible: true, + abortSignal: new AbortController().signal, + }), + ).toBe(false) + }) + + test('leaves Escape available to close a fullscreen overlay', () => { + expect( + shouldHandleCancelRequest({ + wantsCancel: true, + isLoading: true, + isMessageSelectorVisible: false, + isOverlayVisible: true, + abortSignal: new AbortController().signal, + }), + ).toBe(false) + }) }) diff --git a/apps/cli/src/ui/hooks/useCancelRequest.ts b/apps/cli/src/ui/hooks/useCancelRequest.ts index 417dd1e75..fd39d195a 100644 --- a/apps/cli/src/ui/hooks/useCancelRequest.ts +++ b/apps/cli/src/ui/hooks/useCancelRequest.ts @@ -5,16 +5,25 @@ import type { ReactNode } from 'react' import { useKeypress } from '#ui-ink/hooks/useKeypress' import { KEYPRESS_PRIORITY } from '#ui-ink/constants/keypressPriority' +export type CancelRequestGate = { + isPermissionDialogVisible?: boolean + isOverlayVisible?: boolean +} + export function shouldHandleCancelRequest(args: { wantsCancel: boolean isLoading: boolean isMessageSelectorVisible: boolean + isPermissionDialogVisible?: boolean + isOverlayVisible?: boolean abortSignal?: AbortSignal }): boolean { return ( args.wantsCancel && args.isLoading && !args.isMessageSelectorVisible && + !args.isPermissionDialogVisible && + !args.isOverlayVisible && Boolean(args.abortSignal) && !args.abortSignal?.aborted ) @@ -28,6 +37,7 @@ export function useCancelRequest( getIsLoading: () => boolean, isMessageSelectorVisible: boolean, getAbortSignal: () => AbortSignal | undefined, + options?: CancelRequestGate, ) { useKeypress( (input, key) => { @@ -38,10 +48,12 @@ export function useCancelRequest( wantsCancel, isLoading: getIsLoading(), isMessageSelectorVisible, + isPermissionDialogVisible: options?.isPermissionDialogVisible, + isOverlayVisible: options?.isOverlayVisible, abortSignal: getAbortSignal(), }) ) { - // Esc closes the message selector + // Esc closes the message selector, permission dialog, or overlay return undefined } diff --git a/apps/cli/src/ui/hooks/useTextInput.test.ts b/apps/cli/src/ui/hooks/useTextInput.test.ts new file mode 100644 index 000000000..8ccd79696 --- /dev/null +++ b/apps/cli/src/ui/hooks/useTextInput.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { __resolveTextInputDestructiveActionForTests } from './useTextInput' + +describe('text input destructive keys', () => { + test('forward-delete stays distinct from backspace and DEL', () => { + expect( + __resolveTextInputDestructiveActionForTests( + { delete: true, backspace: false }, + '', + ), + ).toBe('delete') + expect( + __resolveTextInputDestructiveActionForTests( + { delete: false, backspace: true }, + '', + ), + ).toBe('backspace') + expect( + __resolveTextInputDestructiveActionForTests( + { delete: false, backspace: false }, + '\u007f', + ), + ).toBe('backspace') + expect( + __resolveTextInputDestructiveActionForTests( + { delete: false, backspace: false }, + 'a', + ), + ).toBeNull() + }) +}) diff --git a/apps/cli/src/ui/hooks/useTextInput.ts b/apps/cli/src/ui/hooks/useTextInput.ts index d6cc25fab..d5a55ea63 100644 --- a/apps/cli/src/ui/hooks/useTextInput.ts +++ b/apps/cli/src/ui/hooks/useTextInput.ts @@ -20,13 +20,25 @@ const DEL_CODE = 127 // \x7f // A slightly longer guard helps prevent cursor jumps / wrong insertion points. const IME_NAVIGATION_GUARD_MS = 150 -// Helper to check if input is a backspace character function isBackspaceChar(input: string): boolean { if (input.length !== 1) return false const code = input.charCodeAt(0) return code === BACKSPACE_CODE || code === DEL_CODE } +export function __resolveTextInputDestructiveActionForTests( + key: Pick, + input: string, +): 'delete' | 'backspace' | null { + if (key.delete && !key.backspace && !isBackspaceChar(input)) { + return 'delete' + } + if (key.backspace || input === '\b' || isBackspaceChar(input)) { + return 'backspace' + } + return null +} + export function useTextInput({ value: originalValue, onChange, @@ -330,13 +342,12 @@ export function useTextInput({ return // Skip Tab key processing - let completion system handle it } - // Direct handling for backspace or delete (which is being detected as delete) - if ( - key.backspace || - key.delete || - input === '\b' || - isBackspaceChar(input) - ) { + const destructive = __resolveTextInputDestructiveActionForTests(key, input) + if (destructive === 'delete') { + applyCursor(getCursor().del()) + return + } + if (destructive === 'backspace') { applyCursor(getCursor().backspace()) return } @@ -368,8 +379,12 @@ export function useTextInput({ } function mapKey(key: Key): (input: string) => MaybeCursor { - // Direct handling for backspace or delete - if (key.backspace || key.delete) { + const destructive = __resolveTextInputDestructiveActionForTests(key, '') + if (destructive === 'delete') { + maybeClearImagePasteErrorTimeout() + return () => getCursor().del() + } + if (destructive === 'backspace') { maybeClearImagePasteErrorTimeout() return () => getCursor().backspace() } diff --git a/apps/cli/src/ui/hooks/useUnifiedCompletion/hook.test.ts b/apps/cli/src/ui/hooks/useUnifiedCompletion/hook.test.ts index 27af8ac50..889d92955 100644 --- a/apps/cli/src/ui/hooks/useUnifiedCompletion/hook.test.ts +++ b/apps/cli/src/ui/hooks/useUnifiedCompletion/hook.test.ts @@ -236,6 +236,22 @@ describe('__shouldLoadMentionSuggestionsForTests', () => { ).toBe(true) }) + test('does not load mention providers for @ path completions', () => { + expect( + __shouldLoadMentionSuggestionsForTests({ + isEnabled: true, + currentContext: { + type: 'file', + prefix: 'src/', + startPos: 0, + endPos: 5, + trigger: '@', + }, + activeContext: null, + }), + ).toBe(false) + }) + test('does not load mention providers when completion is disabled', () => { expect( __shouldLoadMentionSuggestionsForTests({ diff --git a/apps/cli/src/ui/hooks/useUnifiedCompletion/useAutoTrigger.test.ts b/apps/cli/src/ui/hooks/useUnifiedCompletion/useAutoTrigger.test.ts index bac85a069..25cce5d7a 100644 --- a/apps/cli/src/ui/hooks/useUnifiedCompletion/useAutoTrigger.test.ts +++ b/apps/cli/src/ui/hooks/useUnifiedCompletion/useAutoTrigger.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test' import { __computeAutoTriggerActionForTests, __getSuppressWakeDelayForTests, + __shouldAutoTriggerCompletionForTests, } from './useAutoTrigger' import type { @@ -190,6 +191,32 @@ describe('__computeAutoTriggerActionForTests', () => { }) }) +describe('__shouldAutoTriggerCompletionForTests', () => { + test('auto-triggers Windows-style file prefixes', () => { + expect( + __shouldAutoTriggerCompletionForTests({ + type: 'file', + prefix: 'src\\ma', + startPos: 0, + endPos: 6, + trigger: null, + }), + ).toBe(true) + }) + + test('does not auto-trigger a bare file word', () => { + expect( + __shouldAutoTriggerCompletionForTests({ + type: 'file', + prefix: 'readme', + startPos: 0, + endPos: 6, + trigger: null, + }), + ).toBe(false) + }) +}) + describe('__getSuppressWakeDelayForTests', () => { test('returns the remaining suppression delay when enabled', () => { expect( diff --git a/apps/cli/src/ui/hooks/useUnifiedCompletion/useAutoTrigger.ts b/apps/cli/src/ui/hooks/useUnifiedCompletion/useAutoTrigger.ts index ad7f75bd0..3389eb0f6 100644 --- a/apps/cli/src/ui/hooks/useUnifiedCompletion/useAutoTrigger.ts +++ b/apps/cli/src/ui/hooks/useUnifiedCompletion/useAutoTrigger.ts @@ -15,10 +15,13 @@ function shouldAutoTrigger(context: CompletionContext): boolean { const prefix = context.prefix if ( prefix.startsWith('./') || + prefix.startsWith('.\\') || prefix.startsWith('../') || + prefix.startsWith('..\\') || prefix.startsWith('/') || prefix.startsWith('~') || - prefix.includes('/') + prefix.includes('/') || + prefix.includes('\\') ) { return true } @@ -32,6 +35,12 @@ function shouldAutoTrigger(context: CompletionContext): boolean { } } +export function __shouldAutoTriggerCompletionForTests( + context: CompletionContext, +): boolean { + return shouldAutoTrigger(context) +} + export function __computeAutoTriggerActionForTests(args: { input: string previousInput: string diff --git a/apps/cli/src/ui/hooks/useUnifiedCompletion/useNavigationKeys.test.ts b/apps/cli/src/ui/hooks/useUnifiedCompletion/useNavigationKeys.test.ts new file mode 100644 index 000000000..d71382f70 --- /dev/null +++ b/apps/cli/src/ui/hooks/useUnifiedCompletion/useNavigationKeys.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test' +import { __completionEnterActionForTests } from './useNavigationKeys' + +describe('completion Enter', () => { + test('sends slash commands on the same keypress', () => { + expect(__completionEnterActionForTests('command')).toBe('accept-and-submit') + }) + + test('submits typed file input and only inserts mention completions', () => { + expect(__completionEnterActionForTests('file')).toBe('submit') + expect(__completionEnterActionForTests('agent')).toBe('accept') + }) +}) diff --git a/apps/cli/src/ui/hooks/useUnifiedCompletion/useNavigationKeys.ts b/apps/cli/src/ui/hooks/useUnifiedCompletion/useNavigationKeys.ts index f69ba87cf..7c13d65bc 100644 --- a/apps/cli/src/ui/hooks/useUnifiedCompletion/useNavigationKeys.ts +++ b/apps/cli/src/ui/hooks/useUnifiedCompletion/useNavigationKeys.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react' import { useKeypress } from '#ui-ink/hooks/useKeypress' import { KEYPRESS_PRIORITY } from '#ui-ink/constants/keypressPriority' +import { emptyDirectoryCompletionMessage } from '#cli-utils/completion/copy' import { isLoadingSuggestion, type CompletionContext, @@ -9,6 +10,14 @@ import { } from '#cli-utils/completion/types' import type { CompletionState } from './types' +export function __completionEnterActionForTests( + contextType: CompletionContext['type'], +): 'accept' | 'accept-and-submit' | 'submit' { + if (contextType === 'command') return 'accept-and-submit' + if (contextType === 'file') return 'submit' + return 'accept' +} + function getPreviewText( suggestion: UnifiedSuggestion, context: CompletionContext, @@ -106,8 +115,9 @@ export function useUnifiedCompletionNavigationKeys(args: { return false } - // Plain Enter keeps chat semantics: close completions and let TextInput - // submit the current value on the same keypress. + // Commands accept the highlighted name and submit it (`/hel` → + // `/help`). Paths retain the typed input so the first Enter always + // submits; Tab or Right Arrow explicitly completes a path/directory. if ( key.return && !key.shift && @@ -115,6 +125,55 @@ export function useUnifiedCompletionNavigationKeys(args: { args.state.isActive && args.state.suggestions.length > 0 ) { + const context = args.state.context + const selectedSuggestion = + args.state.suggestions[args.state.selectedIndex] + if ( + !context || + !selectedSuggestion || + isLoadingSuggestion(selectedSuggestion) + ) { + clearCompletionTimers() + args.resetCompletion() + return false + } + + if (__completionEnterActionForTests(context.type) === 'accept') { + clearCompletionTimers() + const completedInput = args.completeWith(selectedSuggestion, context) + args.resetCompletion() + + const isDirectory = selectedSuggestion.value.endsWith('/') + if (isDirectory && completedInput !== null) { + directoryFollowupTimeoutRef.current = setTimeout(() => { + directoryFollowupTimeoutRef.current = null + if (!mountedRef.current) return + if (inputRef.current !== completedInput) return + + const inserted = getPreviewText(selectedSuggestion, context) + const nextEndPos = context.startPos + inserted.length + const newContext: CompletionContext = { + ...context, + prefix: selectedSuggestion.value, + endPos: nextEndPos, + } + + const newSuggestions = args.generateSuggestions(newContext) + if (newSuggestions.length > 0) { + args.activateCompletion(newSuggestions, newContext) + } else { + args.updateState({ + emptyDirMessage: emptyDirectoryCompletionMessage( + selectedSuggestion.value, + ), + }) + scheduleEmptyDirMessageClear() + } + }, 50) + } + return true + } + clearCompletionTimers() args.resetCompletion() return false @@ -234,7 +293,9 @@ export function useUnifiedCompletionNavigationKeys(args: { args.activateCompletion(newSuggestions, newContext) } else { args.updateState({ - emptyDirMessage: `Directory is empty: ${selectedSuggestion.value}`, + emptyDirMessage: emptyDirectoryCompletionMessage( + selectedSuggestion.value, + ), }) scheduleEmptyDirMessageClear() } diff --git a/apps/cli/src/ui/hooks/useUnifiedCompletion/useTabKey.test.ts b/apps/cli/src/ui/hooks/useUnifiedCompletion/useTabKey.test.ts new file mode 100644 index 000000000..07c61a76c --- /dev/null +++ b/apps/cli/src/ui/hooks/useUnifiedCompletion/useTabKey.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test' +import { + __commandTabCompletionActionForTests, + __completionPreviewOriginalInputForTests, +} from './useTabKey' + +describe('command Tab completion', () => { + test('shows the list when the first Tab has more than one command', () => { + expect( + __commandTabCompletionActionForTests({ + isAlreadyActive: false, + suggestionCount: 3, + }), + ).toBe('show-list') + }) + + test('accepts a unique command on the first Tab', () => { + expect( + __commandTabCompletionActionForTests({ + isAlreadyActive: false, + suggestionCount: 1, + }), + ).toBe('accept-first') + }) + + test('accepts the highlighted command once the panel is open', () => { + expect( + __commandTabCompletionActionForTests({ + isAlreadyActive: true, + suggestionCount: 3, + }), + ).toBe('accept-selected') + }) +}) + +describe('Tab preview original input', () => { + test('keeps the first typed input across later Tab cycles', () => { + expect( + __completionPreviewOriginalInputForTests({ + currentInput: 'src/main.ts', + existingPreview: { isActive: true, originalInput: 'src/ma' }, + }), + ).toBe('src/ma') + }) + + test('records the current input when a preview is not yet active', () => { + expect( + __completionPreviewOriginalInputForTests({ + currentInput: 'src/ma', + existingPreview: null, + }), + ).toBe('src/ma') + }) +}) diff --git a/apps/cli/src/ui/hooks/useUnifiedCompletion/useTabKey.ts b/apps/cli/src/ui/hooks/useUnifiedCompletion/useTabKey.ts index 3d149822e..8c2e92c62 100644 --- a/apps/cli/src/ui/hooks/useUnifiedCompletion/useTabKey.ts +++ b/apps/cli/src/ui/hooks/useUnifiedCompletion/useTabKey.ts @@ -14,6 +14,23 @@ export function __shouldHandleUnifiedCompletionTabKeyForTests( return Boolean(key.tab) && !Boolean(key.shift) } +export function __commandTabCompletionActionForTests(args: { + isAlreadyActive: boolean + suggestionCount: number +}): 'accept-selected' | 'accept-first' | 'show-list' { + if (args.isAlreadyActive) return 'accept-selected' + if (args.suggestionCount <= 1) return 'accept-first' + return 'show-list' +} + +export function __completionPreviewOriginalInputForTests(args: { + currentInput: string + existingPreview: { isActive: boolean; originalInput: string } | null +}): string { + if (args.existingPreview?.isActive) return args.existingPreview.originalInput + return args.currentInput +} + export function useUnifiedCompletionTabKey(args: { input: string state: CompletionState @@ -96,7 +113,10 @@ export function useUnifiedCompletionTabKey(args: { selectedIndex: nextIndex, preview: { isActive: true, - originalInput: args.input, + originalInput: __completionPreviewOriginalInputForTests({ + currentInput: args.input, + existingPreview: args.state.preview, + }), wordRange: [ args.state.context.startPos, args.state.context.startPos + preview.length, @@ -120,6 +140,15 @@ export function useUnifiedCompletionTabKey(args: { return true } + const action = __commandTabCompletionActionForTests({ + isAlreadyActive: false, + suggestionCount: currentSuggestions.length, + }) + if (action === 'show-list') { + args.activateCompletion(currentSuggestions, context) + return true + } + args.completeWith(firstSuggestion, context) args.resetCompletion() return true @@ -162,7 +191,10 @@ export function useUnifiedCompletionTabKey(args: { args.updateState({ preview: { isActive: true, - originalInput: args.input, + originalInput: __completionPreviewOriginalInputForTests({ + currentInput: args.input, + existingPreview: args.state.preview, + }), wordRange: [context.startPos, context.startPos + preview.length], }, }) diff --git a/apps/cli/src/ui/screens/LspStatus.tsx b/apps/cli/src/ui/screens/LspStatus.tsx index eeafbcefa..a7a51f95c 100644 --- a/apps/cli/src/ui/screens/LspStatus.tsx +++ b/apps/cli/src/ui/screens/LspStatus.tsx @@ -134,7 +134,7 @@ export function LspStatus({ onDone }: Props): React.ReactNode { > ✘ LSP status check failed - {state.message} + {state.message} @@ -154,7 +154,7 @@ export function LspStatus({ onDone }: Props): React.ReactNode { > - + IDE MCP connected: {state.ideMcpConnected ? 'yes' : 'no'} @@ -168,26 +168,33 @@ export function LspStatus({ onDone }: Props): React.ReactNode { Active servers (this session) {!state.runtime.hasManager ? ( - + Not initialized yet. LSP servers start automatically once configured and initialized. ) : activeServers.length === 0 ? ( - No servers running yet. + No servers running yet. ) : ( <> - - Running: {runningServers.length} • Active:{' '} + + Running: {runningServers.length} · Active:{' '} {activeServers.length} {activeServers.slice(0, 8).map(s => ( - + • {s.name} — {s.state} {s.pid ? ` (pid ${s.pid})` : ''} ))} {activeServers.length > 8 ? ( - …and {activeServers.length - 8} more + + …and {activeServers.length - 8} more + ) : null} )} @@ -203,18 +210,21 @@ export function LspStatus({ onDone }: Props): React.ReactNode { const resolvedPath = resolveCommandPath(server) return ( - + • {server.name} ({summarizeSource(server.source)}) —{' '} {extCount} ext {resolvedPath ? ( - ↳ bin: {resolvedPath} + + {' '} + ↳ bin: {resolvedPath} + ) : null} ) })} {summary.servers.length > runnablePreview.length ? ( - + …and {summary.servers.length - runnablePreview.length} more ) : null} @@ -222,7 +232,9 @@ export function LspStatus({ onDone }: Props): React.ReactNode { ) : ( No LSP servers configured. - Configure LSP servers via enabled plugins. + + Configure LSP servers via enabled plugins. + )} diff --git a/apps/cli/src/ui/screens/REPL/REPLView.static.test.tsx b/apps/cli/src/ui/screens/REPL/REPLView.static.test.tsx index 72445c1ef..add33d661 100644 --- a/apps/cli/src/ui/screens/REPL/REPLView.static.test.tsx +++ b/apps/cli/src/ui/screens/REPL/REPLView.static.test.tsx @@ -440,13 +440,13 @@ describe('REPLView Static output epoch', () => { ) await harness.wait(480) - expect(harness.getOutput()).toContain('Local Tasks') + expect(harness.getOutput()).toContain('Local agents & shells') harness.clearOutput() harness.resize(80, 24) await harness.wait(80) - expect(harness.getOutput()).toContain('Local Tasks') + expect(harness.getOutput()).toContain('Local agents & shells') }) test('keeps transient output visible when prompt text changes within the same height', async () => { diff --git a/apps/cli/src/ui/screens/REPL/REPLView.tsx b/apps/cli/src/ui/screens/REPL/REPLView.tsx index a0495786c..d5b813974 100644 --- a/apps/cli/src/ui/screens/REPL/REPLView.tsx +++ b/apps/cli/src/ui/screens/REPL/REPLView.tsx @@ -359,7 +359,7 @@ export function REPLView({ : showingCostDialog ? 'Cost notice - expand terminal' : isLoading - ? 'Working… Esc cancel' + ? 'Working… Esc to cancel' : null const hasStaticOutput = staticItems.length > 0 const isStaticOutputObscured = isFullScreenToolView || hasToolUseConfirm diff --git a/apps/cli/src/ui/screens/REPL/useReplController.tsx b/apps/cli/src/ui/screens/REPL/useReplController.tsx index 7a3a51dd0..701ab9588 100644 --- a/apps/cli/src/ui/screens/REPL/useReplController.tsx +++ b/apps/cli/src/ui/screens/REPL/useReplController.tsx @@ -889,7 +889,16 @@ export function useReplController(props: REPLProps) { const { ConfigScreen } = await import('#ui-ink/screens/overlays/ConfigScreen') openToolView({ - jsx: , + jsx: ( + { + setInputMode('prompt') + setInputValue(`${command} `) + showToast(`Command ready: ${command}`) + }} + /> + ), shouldHidePromptInput: true, displayMode: 'fullscreen', }) @@ -1009,7 +1018,16 @@ export function useReplController(props: REPLProps) { const { ConfigScreen } = await import('#ui-ink/screens/overlays/ConfigScreen') openToolView({ - jsx: , + jsx: ( + { + setInputMode('prompt') + setInputValue(`${command} `) + showToast(`Command ready: ${command}`) + }} + /> + ), shouldHidePromptInput: true, displayMode: 'fullscreen', }) @@ -1198,6 +1216,10 @@ export function useReplController(props: REPLProps) { getIsLoading, isMessageSelectorVisible, getAbortSignal, + { + isPermissionDialogVisible: Boolean(toolUseConfirm), + isOverlayVisible: Boolean(toolJSX) || Boolean(binaryFeedbackContext), + }, ) useEffect(() => { diff --git a/apps/cli/src/ui/screens/overlays/CommandPaletteScreen.tsx b/apps/cli/src/ui/screens/overlays/CommandPaletteScreen.tsx index 13a9477bf..82972111c 100644 --- a/apps/cli/src/ui/screens/overlays/CommandPaletteScreen.tsx +++ b/apps/cli/src/ui/screens/overlays/CommandPaletteScreen.tsx @@ -354,8 +354,9 @@ export function CommandPaletteScreen({ > - - Search actions or /commands. Enter opens or inserts. Esc closes. + + Search actions or /commands. Enter opens or inserts. Esc clears + search, again closes. {figures.pointerSmall} @@ -382,14 +383,14 @@ export function CommandPaletteScreen({ - + {status ?? (filtered.length === 0 ? 'No matches' : `Showing ${visible.length} of ${filtered.length} (${actionCount} actions, ${commandCount} commands)`)} - + {showUp ? `${figures.arrowUp} More` : ' '} {visible.map((action, idx) => { @@ -415,7 +416,7 @@ export function CommandPaletteScreen({ ) })} - + {showDown ? `${figures.arrowDown} More` : ' '} @@ -430,8 +431,8 @@ export function CommandPaletteScreen({ ) : null} - - Arrows - PgUp/PgDn - Home/End - Enter select - Esc clear/close + + Arrows · PgUp/PgDn · Home/End · Enter select · Esc clear/close diff --git a/apps/cli/src/ui/screens/overlays/ConfigScreen.test.ts b/apps/cli/src/ui/screens/overlays/ConfigScreen.test.ts new file mode 100644 index 000000000..050b2bafa --- /dev/null +++ b/apps/cli/src/ui/screens/overlays/ConfigScreen.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test' +import { __getConfigQuickStartCommandForTests } from './ConfigScreen' + +describe('ConfigScreen quick start', () => { + test('maps 1-4 to setup commands and ignores other keys', () => { + expect(__getConfigQuickStartCommandForTests('1')).toBe('/onboarding') + expect(__getConfigQuickStartCommandForTests('2')).toBe('/model') + expect(__getConfigQuickStartCommandForTests('3')).toBe('/permissions') + expect(__getConfigQuickStartCommandForTests('4')).toBe('/mcp') + expect(__getConfigQuickStartCommandForTests('5')).toBeNull() + expect(__getConfigQuickStartCommandForTests('a')).toBeNull() + }) +}) diff --git a/apps/cli/src/ui/screens/overlays/ConfigScreen.tsx b/apps/cli/src/ui/screens/overlays/ConfigScreen.tsx index e617ea36e..7140a0e42 100644 --- a/apps/cli/src/ui/screens/overlays/ConfigScreen.tsx +++ b/apps/cli/src/ui/screens/overlays/ConfigScreen.tsx @@ -38,6 +38,28 @@ const THEME_OPTIONS: ThemeNames[] = [ type Props = { onClose: () => void + onSelectCommand?: (command: string) => void +} + +export const CONFIG_QUICK_START_COMMANDS = [ + '/onboarding', + '/model', + '/permissions', + '/mcp', +] as const + +export function __getConfigQuickStartCommandForTests( + inputChar: string, +): (typeof CONFIG_QUICK_START_COMMANDS)[number] | null { + const index = Number.parseInt(inputChar, 10) - 1 + if ( + !Number.isInteger(index) || + index < 0 || + index >= CONFIG_QUICK_START_COMMANDS.length + ) { + return null + } + return CONFIG_QUICK_START_COMMANDS[index] ?? null } type Setting = @@ -75,7 +97,10 @@ type Setting = disabled?: boolean } -export function ConfigScreen({ onClose }: Props): React.ReactNode { +export function ConfigScreen({ + onClose, + onSelectCommand, +}: Props): React.ReactNode { const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()) const initialConfig = React.useRef(getGlobalConfig()) const exitState = { pending: false, keyName: null as null } as const @@ -289,6 +314,7 @@ export function ConfigScreen({ onClose }: Props): React.ReactNode { setEditingString(false) setCurrentInput('') setInputError(null) + return true } else if (key.delete || key.backspace) { setCurrentInput(prev => prev.slice(0, -1)) } else if (input) { @@ -298,6 +324,13 @@ export function ConfigScreen({ onClose }: Props): React.ReactNode { } if (view === 'quick-start') { + const quickStartCommand = + __getConfigQuickStartCommandForTests(inputChar) + if (quickStartCommand) { + onSelectCommand?.(quickStartCommand) + safeOnClose() + return true + } if (key.tab || key.rightArrow || inputChar === 'a') { setView('advanced') return true @@ -340,6 +373,7 @@ export function ConfigScreen({ onClose }: Props): React.ReactNode { } safeOnClose() + return true } return undefined @@ -412,8 +446,11 @@ export function ConfigScreen({ onClose }: Props): React.ReactNode { models, and key validation. Keys are never shown here. - 3. /permissions and{' '} - /mcp manage tool access and + 3. /permissions manages tool + access. + + + 4. /mcp manages integrations. @@ -498,7 +535,7 @@ export function ConfigScreen({ onClose }: Props): React.ReactNode { {editingString ? 'Enter to save · Esc to cancel' : view === 'quick-start' - ? 'Tab/a advanced preferences · Esc close' + ? '1-4 open command · Tab/a advanced preferences · Esc close' : '↑/↓ or j/k · Home/End · Enter change · Tab back · Esc close'} diff --git a/apps/cli/src/ui/screens/overlays/HelpScreen.test.ts b/apps/cli/src/ui/screens/overlays/HelpScreen.test.ts index 75fbdd61a..12751e683 100644 --- a/apps/cli/src/ui/screens/overlays/HelpScreen.test.ts +++ b/apps/cli/src/ui/screens/overlays/HelpScreen.test.ts @@ -55,10 +55,17 @@ describe('HelpScreen helpers', () => { it('describes the transcript shortcut consistently with F6', () => { const lines = __buildHelpLinesForTests([]) + expect(lines).toContain( + '- Ctrl+S: Stash prompt (press again on empty input to restore)', + ) + expect(lines).toContain('- F8: Local agents and shells') + expect(lines).toContain('- Ctrl+T: Work / todo list') expect(lines).toContain('- Ctrl+O: Transcript (scroll/copy)') expect(lines.some(line => line.includes('Toggle verbose transcript'))).toBe( false, ) + expect(lines).toContain(' > /bash ls') + expect(lines.some(line => line.includes('!ls'))).toBe(false) // The fictional "Down Arrow opens Tasks" claim is gone. expect( lines.some(line => line.includes('Down Arrow (empty input): Tasks')), @@ -91,6 +98,7 @@ describe('HelpScreen helpers', () => { const defaultLines = __buildHelpLinesForTests([helpCommand, themeCommand]) expect(defaultLines.some(line => line.includes('/theme'))).toBe(false) expect(defaultLines.some(line => line.includes('/help'))).toBe(true) + expect(defaultLines.some(line => line.includes('/help all'))).toBe(true) // /help all shows the whole catalog with category section headers. const allLines = __buildHelpLinesForTests([helpCommand, themeCommand], { diff --git a/apps/cli/src/ui/screens/overlays/HelpScreen.tsx b/apps/cli/src/ui/screens/overlays/HelpScreen.tsx index 744a8a876..e189e745e 100644 --- a/apps/cli/src/ui/screens/overlays/HelpScreen.tsx +++ b/apps/cli/src/ui/screens/overlays/HelpScreen.tsx @@ -118,9 +118,9 @@ export function __buildHelpLinesForTests( lines.push('- F5: Notifications') lines.push('- F6: Transcript (scroll/copy)') lines.push('- F7: Command palette (search actions and commands)') - lines.push('- F8: Tasks (background tasks)') + lines.push('- F8: Local agents and shells') lines.push('- Ctrl+O: Transcript (scroll/copy)') - lines.push('- Ctrl+T: Work tasks') + lines.push('- Ctrl+T: Work / todo list') lines.push('- Ctrl+R: History search') lines.push( `- ${shortcutModifier}+P: Model picker (type to filter; Ctrl+O opens model settings)`, @@ -133,7 +133,7 @@ export function __buildHelpLinesForTests( `- ${editorShortcut.trigger}: ${editorShortcut.effect} (Ctrl+G also works)`, ) lines.push(`- Ctrl/${shortcutModifier}+B: Prefill /bash`) - lines.push('- Ctrl+S: Stash prompt') + lines.push('- Ctrl+S: Stash prompt (press again on empty input to restore)') lines.push('- Ctrl+_: Undo') lines.push('- Double Esc: Clear input') lines.push(`- ${modeCycleShortcut.displayText}: Cycle permission mode`) @@ -152,7 +152,7 @@ export function __buildHelpLinesForTests( lines.push('- Edit files') lines.push(' > Update bar.ts to...') lines.push('- Run bash commands') - lines.push(' > !ls') + lines.push(' > /bash ls') lines.push('') lines.push('Commands') @@ -243,6 +243,7 @@ export function HelpScreen({ [onDone], ) + const [showFullCatalog, setShowFullCatalog] = useState(showAll) const [scrollTop, setScrollTop] = useState(0) const [status, setStatus] = useState(null) const [savedPath, setSavedPath] = useState(null) @@ -258,8 +259,8 @@ export function HelpScreen({ }, []) const rawLines = useMemo( - () => __buildHelpLinesForTests(commands, { showAll }), - [commands, showAll], + () => __buildHelpLinesForTests(commands, { showAll: showFullCatalog }), + [commands, showFullCatalog], ) const wrapped = useMemo(() => { const width = Math.max(1, layout.columns - layout.paddingX * 2) @@ -374,6 +375,12 @@ export function HelpScreen({ return true } + if (inputChar === 'a') { + setShowFullCatalog(prev => !prev) + setScrollTop(0) + return true + } + return undefined }, { priority: KEYPRESS_PRIORITY.FULLSCREEN_OVERLAY }, @@ -412,14 +419,15 @@ export function HelpScreen({ gap={layout.gap} > - - Scroll: ↑↓ j/k PgUp/PgDn Home/End · y copy · s save · Esc/Ctrl+C close + + Scroll: ↑↓ j/k PgUp/PgDn Home/End · a full catalog · y copy · s save · + Esc/Ctrl+C close {statusLine} - + {topIndicator} {visible.length > 0 ? ( @@ -429,13 +437,13 @@ export function HelpScreen({ )) ) : ( - (empty) + (empty) )} - + {bottomIndicator} - + {savedPath ? `Saved: ${savedPath}` : `Tip: press 's' to save to ${getHelpPath()}`} diff --git a/apps/cli/src/ui/screens/overlays/HistorySearchScreen.tsx b/apps/cli/src/ui/screens/overlays/HistorySearchScreen.tsx index aeeab840f..7158fe67f 100644 --- a/apps/cli/src/ui/screens/overlays/HistorySearchScreen.tsx +++ b/apps/cli/src/ui/screens/overlays/HistorySearchScreen.tsx @@ -217,7 +217,7 @@ export function HistorySearchScreen({ gap={layout.gap} > - + {shortcutLine} @@ -245,7 +245,7 @@ export function HistorySearchScreen({ - + {status ?? (filtered.length === 0 ? 'No matches' @@ -267,12 +267,12 @@ export function HistorySearchScreen({ ) }) ) : ( - (empty) + (empty) )} - - Tip: history includes bash commands with a leading `!` + + Tip: history includes /bash commands diff --git a/apps/cli/src/ui/screens/overlays/ShortcutsScreen.test.ts b/apps/cli/src/ui/screens/overlays/ShortcutsScreen.test.ts new file mode 100644 index 000000000..c39fa95fc --- /dev/null +++ b/apps/cli/src/ui/screens/overlays/ShortcutsScreen.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { __buildShortcutRowsForTests } from './ShortcutsScreen' + +describe('ShortcutsScreen rows', () => { + test('keeps setup commands and stash/palette keys discoverable', () => { + const rows = __buildShortcutRowsForTests({ platform: 'linux' }) + + expect(rows.commandRows.map(row => row.label)).toEqual([ + '/config', + '/help', + '/model', + '/init', + '@path', + ]) + expect(rows.inputRows.some(row => row.label === 'Ctrl+S')).toBe(true) + expect(rows.systemRows.some(row => row.label === 'F7')).toBe(true) + expect(rows.systemRows.some(row => row.label === 'F1')).toBe(true) + expect(rows.narrowRows.map(row => row.label)).toContain('F7') + expect(rows.narrowRows.map(row => row.label)).toContain('Ctrl+S') + expect(rows.inputRows.some(row => row.label === '/bash ')).toBe(true) + expect(rows.inputRows.some(row => row.label.startsWith('!'))).toBe(false) + }) +}) diff --git a/apps/cli/src/ui/screens/overlays/ShortcutsScreen.tsx b/apps/cli/src/ui/screens/overlays/ShortcutsScreen.tsx index ee47ac16a..5351f4166 100644 --- a/apps/cli/src/ui/screens/overlays/ShortcutsScreen.tsx +++ b/apps/cli/src/ui/screens/overlays/ShortcutsScreen.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo, useRef } from 'react' import { Box, Text } from 'ink' import { useKeypress } from '#ui-ink/hooks/useKeypress' +import { KEYPRESS_PRIORITY } from '#ui-ink/constants/keypressPriority' import { ScreenFrame } from '#ui-ink/primitives/layout/ScreenFrame' import { useScreenLayout } from '#ui-ink/primitives/layout/useScreenLayout' import { getTheme, type Theme } from '#core/utils/theme' @@ -63,21 +64,19 @@ function ShortcutColumn({ ) } -export function ShortcutsScreen({ onDone }: Props): React.ReactNode { - const theme = getTheme() - const layout = useScreenLayout() - const exitState = { pending: false, keyName: null as null } as const - const didDoneRef = useRef(false) - - const safeOnDone = useCallback(() => { - if (didDoneRef.current) return - didDoneRef.current = true - onDone() - }, [onDone]) - - const modeCycleShortcut = useMemo(() => getPermissionModeCycleShortcut(), []) - const { commands, shortcuts } = useMemo(() => getCommandShortcutHints(), []) - const shortcutModifier = getShortcutModifierLabel() +export function __buildShortcutRowsForTests(options?: { + voiceEnabled?: boolean + platform?: NodeJS.Platform +}): { + commandRows: ShortcutRow[] + inputRows: ShortcutRow[] + systemRows: ShortcutRow[] + narrowRows: ShortcutRow[] +} { + const platform = options?.platform + const { commands, shortcuts } = getCommandShortcutHints(platform) + const shortcutModifier = getShortcutModifierLabel(platform) + const modeCycleShortcut = getPermissionModeCycleShortcut() const modelShortcut = shortcuts[0] ?? { trigger: 'Alt+M', effect: 'switch model', @@ -86,7 +85,7 @@ export function ShortcutsScreen({ onDone }: Props): React.ReactNode { trigger: 'Alt+G', effect: 'open external editor', } - const voiceShortcut = isExperimentalVoiceEnabled() + const voiceShortcut = options?.voiceEnabled ? { label: 'F10', detail: 'voice conversation; tap to start/stop recording', @@ -94,15 +93,6 @@ export function ShortcutsScreen({ onDone }: Props): React.ReactNode { } : null - useKeypress((input, key) => { - const inputChar = input.length === 1 ? input : '' - if (key.escape || inputChar === '?' || (key.ctrl && inputChar === 'c')) { - safeOnDone() - return true - } - return undefined - }) - const commandRows: ShortcutRow[] = [ ...commands.map(command => ({ label: command.trigger, @@ -112,7 +102,7 @@ export function ShortcutsScreen({ onDone }: Props): React.ReactNode { { label: '@path', detail: 'insert file path', tone: 'command' }, ] const inputRows: ShortcutRow[] = [ - { label: '! ', detail: 'run shell command', tone: 'command' }, + { label: '/bash ', detail: 'run shell command', tone: 'command' }, { label: '& ', detail: 'run in background', tone: 'command' }, { label: `Ctrl/${shortcutModifier}+B`, @@ -130,6 +120,12 @@ export function ShortcutsScreen({ onDone }: Props): React.ReactNode { detail: 'insert newline', tone: 'shortcut', }, + { + label: 'Ctrl+S', + detail: 'stash prompt; again restores', + tone: 'shortcut', + }, + { label: 'Alt+Up', detail: 'edit latest queued prompt', tone: 'shortcut' }, ] const systemRows: ShortcutRow[] = [ { @@ -147,6 +143,8 @@ export function ShortcutsScreen({ onDone }: Props): React.ReactNode { detail: 'thinking mode', tone: 'shortcut', }, + { label: 'F7', detail: 'command palette', tone: 'shortcut' }, + { label: 'F1', detail: 'full help', tone: 'shortcut' }, { label: 'Ctrl+O', detail: 'transcript output', tone: 'shortcut' }, { label: 'Ctrl+T', detail: 'work tasks', tone: 'shortcut' }, { label: 'Ctrl+_', detail: 'undo', tone: 'shortcut' }, @@ -155,12 +153,61 @@ export function ShortcutsScreen({ onDone }: Props): React.ReactNode { { label: 'Esc', detail: 'close', tone: 'shortcut' }, ] const narrowRows: ShortcutRow[] = [ - ...systemRows.slice(0, 2), - ...inputRows.slice(2, 4), - ...systemRows.slice(2, 4), - voiceShortcut ?? - systemRows[6] ?? { label: 'Esc', detail: 'close', tone: 'shortcut' }, + { label: 'F7', detail: 'command palette', tone: 'shortcut' }, + { + label: 'Ctrl+S', + detail: 'stash prompt; again restores', + tone: 'shortcut', + }, + { + label: modeCycleShortcut.displayText, + detail: 'cycle tool permission mode', + tone: 'shortcut', + }, + { + label: modelShortcut.trigger, + detail: modelShortcut.effect, + tone: 'shortcut', + }, + { label: '/bash ', detail: 'run shell command', tone: 'command' }, + voiceShortcut ?? { label: 'Esc', detail: 'close', tone: 'shortcut' }, ] + + return { commandRows, inputRows, systemRows, narrowRows } +} + +export function ShortcutsScreen({ onDone }: Props): React.ReactNode { + const theme = getTheme() + const layout = useScreenLayout() + const exitState = { pending: false, keyName: null as null } as const + const didDoneRef = useRef(false) + + const safeOnDone = useCallback(() => { + if (didDoneRef.current) return + didDoneRef.current = true + onDone() + }, [onDone]) + + const rows = useMemo( + () => + __buildShortcutRowsForTests({ + voiceEnabled: isExperimentalVoiceEnabled(), + }), + [], + ) + + useKeypress( + (input, key) => { + const inputChar = input.length === 1 ? input : '' + if (key.escape || inputChar === '?' || (key.ctrl && inputChar === 'c')) { + safeOnDone() + return true + } + return undefined + }, + { priority: KEYPRESS_PRIORITY.FULLSCREEN_OVERLAY }, + ) + const wide = layout.columns >= 110 const gap = Math.max(2, layout.gap) const contentWidth = Math.max(1, layout.columns - layout.paddingX * 2 - 2) @@ -176,14 +223,25 @@ export function ShortcutsScreen({ onDone }: Props): React.ReactNode { paddingY={layout.paddingY} gap={layout.gap} > - - - - {wide ? : null} + + + + + {wide ? ( + + ) : null} + + + F1 full help · Esc close + ) diff --git a/apps/cli/src/ui/screens/overlays/StatusScreen.tsx b/apps/cli/src/ui/screens/overlays/StatusScreen.tsx index 4f4bc909f..b2c8b2f0d 100644 --- a/apps/cli/src/ui/screens/overlays/StatusScreen.tsx +++ b/apps/cli/src/ui/screens/overlays/StatusScreen.tsx @@ -7,6 +7,8 @@ import type { ToolUseContext } from '#core/tooling/Tool' import { getDisableAllHooksState } from '@kode/hooks/disableAllHooks' import { getModelManager } from '#core/utils/model' import { getTheme } from '#core/utils/theme' +import type { PermissionMode } from '#core/types/PermissionMode' +import { getPermissionModeStatusLabel } from '#ui-ink/utils/permissionModeDisplay' import { getCwd } from '#core/utils/state' import { getKodeAgentSessionId } from '#protocol/utils/kodeAgentSessionId' import { useKeypress } from '#ui-ink/hooks/useKeypress' @@ -56,8 +58,11 @@ function buildStatusLines(args: { const connectedMcp = mcpClients.filter((c: any) => c?.type === 'connected') const failedMcp = mcpClients.filter((c: any) => c?.type !== 'connected') + const rawPermissionMode = args.context.options?.permissionMode const permissionMode = - (args.context.options?.permissionMode as string | undefined) ?? '(default)' + typeof rawPermissionMode === 'string' + ? getPermissionModeStatusLabel(rawPermissionMode as PermissionMode) + : getPermissionModeStatusLabel('cautious') const lines: string[] = [] lines.push('Session') @@ -65,7 +70,7 @@ function buildStatusLines(args: { lines.push(`- session_id: ${getKodeAgentSessionId()}`) lines.push(`- cwd: ${cwd}`) lines.push(`- safe_mode: ${args.context.safeMode ? 'on' : 'off'}`) - lines.push(`- permission_mode: ${permissionMode}`) + lines.push(`- permission policy: ${permissionMode}`) lines.push('') lines.push('Connectivity') diff --git a/apps/cli/src/ui/screens/overlays/TasksScreen.test.ts b/apps/cli/src/ui/screens/overlays/TasksScreen.test.ts index f3d7a2e42..181e490fd 100644 --- a/apps/cli/src/ui/screens/overlays/TasksScreen.test.ts +++ b/apps/cli/src/ui/screens/overlays/TasksScreen.test.ts @@ -6,9 +6,24 @@ import { __flattenTasksTreeForTests, __getPreferredSelectedIndexForTests, __nextTaskFilterForTests, + __resolveTasksDetailKeyActionForTests, } from './TasksScreen' describe('TasksScreen helpers', () => { + test('treats detail Escape as back, not overlay close', () => { + expect(__resolveTasksDetailKeyActionForTests({ escape: true })).toBe('back') + expect(__resolveTasksDetailKeyActionForTests({ leftArrow: true })).toBe( + 'back', + ) + expect(__resolveTasksDetailKeyActionForTests({ q: true })).toBe('back') + expect(__resolveTasksDetailKeyActionForTests({ return: true })).toBe( + 'close', + ) + expect(__resolveTasksDetailKeyActionForTests({ space: true })).toBe('close') + expect(__resolveTasksDetailKeyActionForTests({ ctrlC: true })).toBe('close') + expect(__resolveTasksDetailKeyActionForTests({ k: true })).toBe('kill') + }) + test('filters local task snapshots without treating them as durable history', () => { const tasks = [ { taskId: 'running', status: 'running' }, diff --git a/apps/cli/src/ui/screens/overlays/TasksScreen.tsx b/apps/cli/src/ui/screens/overlays/TasksScreen.tsx index b7b4eb612..68ee9ce16 100644 --- a/apps/cli/src/ui/screens/overlays/TasksScreen.tsx +++ b/apps/cli/src/ui/screens/overlays/TasksScreen.tsx @@ -116,7 +116,23 @@ function aggregateStatus( } function statusLabel(status: BackgroundTaskStatus | null): string { - return status ?? 'idle' + if (status === 'pending') return 'queued' + return status ?? 'unknown' +} + +export function __resolveTasksDetailKeyActionForTests(key: { + escape?: boolean + leftArrow?: boolean + return?: boolean + space?: boolean + ctrlC?: boolean + q?: boolean + k?: boolean +}): 'back' | 'close' | 'kill' | null { + if (key.leftArrow || key.escape || key.q) return 'back' + if (key.k) return 'kill' + if (key.return || key.space || key.ctrlC) return 'close' + return null } function statusIcon(status: BackgroundTaskStatus | null): string { @@ -171,7 +187,14 @@ export function __nextTaskFilterForTests(filter: TaskFilter): TaskFilter { } function taskFilterLabel(filter: TaskFilter): string { - return filter + switch (filter) { + case 'active': + return 'Active' + case 'finished': + return 'Finished' + default: + return 'All' + } } function buildAgentTree(tasks: BackgroundAgentTaskSnapshot[]): TreeNode | null { @@ -685,22 +708,27 @@ export function TasksScreen({ useKeypress( (input, key) => { if (detailTarget) { - if (key.leftArrow) { + const action = __resolveTasksDetailKeyActionForTests({ + escape: key.escape, + leftArrow: key.leftArrow, + return: key.return, + space: input === ' ', + ctrlC: Boolean(key.ctrl && input === 'c'), + q: input === 'q', + k: input === 'k', + }) + + if (action === 'back') { setDetailTarget(null) return true } - if (input === 'k') { + if (action === 'kill') { killDetailTask() return true } - if ( - key.escape || - key.return || - input === ' ' || - (key.ctrl && input === 'c') - ) { + if (action === 'close') { safeOnDone() return true } @@ -857,7 +885,7 @@ export function TasksScreen({ status ?? (totalTasks > 0 ? `Local tasks: ${taskFilterLabel(taskFilter)} · ${runningTasks} running · ${totalTasks} shown` - : `No ${taskFilterLabel(taskFilter)} local tasks`) + : `No local tasks in ${taskFilterLabel(taskFilter)}`) const tipLine = 'Local task snapshots end with this Kode process. Use /runs status for durable-run records.' @@ -893,9 +921,9 @@ export function TasksScreen({ : `Full output: ${outputFile}` const footerActions = [ - '← to go back', - 'Esc/Enter/Space to close', - detailTask?.status === 'running' ? 'k to kill' : null, + '←/Esc back', + 'Enter/Space/Ctrl+C close', + detailTask?.status === 'running' ? 'k kill' : null, ] .filter(Boolean) .join(' · ') @@ -972,7 +1000,7 @@ export function TasksScreen({ return ( diff --git a/apps/cli/src/ui/screens/overlays/WorkTasksScreen.tsx b/apps/cli/src/ui/screens/overlays/WorkTasksScreen.tsx index a12447f49..0ca679944 100644 --- a/apps/cli/src/ui/screens/overlays/WorkTasksScreen.tsx +++ b/apps/cli/src/ui/screens/overlays/WorkTasksScreen.tsx @@ -47,8 +47,8 @@ function WorkTasksEmptyView({ gap={layout.gap} > - {message} - + {message} + Esc or Ctrl+C/Ctrl+T to close @@ -160,28 +160,39 @@ function WorkTasksListView({ gap={layout.gap} > - + {count} {label}: - + {window.showUpIndicator ? `${figures.arrowUp} More` : ' '} {visibleItems.map((item, idx) => { const absoluteIndex = window.start + idx const isSelected = absoluteIndex === selectedIndex + const iconColor = + item.icon === '✔' + ? theme.success + : item.icon === '◼' + ? theme.warning + : theme.secondaryText return ( {isSelected ? figures.pointer : ' '} - {item.icon} + {item.icon} {item.content.replace(/\s+/g, ' ')} @@ -189,12 +200,12 @@ function WorkTasksListView({ ) })} - + {window.showDownIndicator ? `${figures.arrowDown} More` : ' '} - + ↑/↓ or j/k · PgUp/PgDn · Home/End · Esc/Ctrl+C/Ctrl+T close diff --git a/apps/cli/src/ui/utils/commandShortcutHints.test.ts b/apps/cli/src/ui/utils/commandShortcutHints.test.ts index efca08548..9bdb2efac 100644 --- a/apps/cli/src/ui/utils/commandShortcutHints.test.ts +++ b/apps/cli/src/ui/utils/commandShortcutHints.test.ts @@ -14,10 +14,10 @@ describe('command shortcut hints', () => { const hints = getCommandShortcutHints('win32') expect(hints.commands).toEqual([ - { trigger: '/init', effect: 'create AGENTS.md' }, + { trigger: '/config', effect: 'setup models and tools' }, { trigger: '/help', effect: 'open help' }, - { trigger: '/bash ', effect: 'run shell command' }, - { trigger: '/note ', effect: 'save note to AGENTS.md' }, + { trigger: '/model', effect: 'manage models' }, + { trigger: '/init', effect: 'create AGENTS.md' }, ]) expect(hints.shortcuts).toEqual([ { trigger: 'Alt+M', effect: 'switch model' }, diff --git a/apps/cli/src/ui/utils/commandShortcutHints.ts b/apps/cli/src/ui/utils/commandShortcutHints.ts index 1089729e9..075adac1c 100644 --- a/apps/cli/src/ui/utils/commandShortcutHints.ts +++ b/apps/cli/src/ui/utils/commandShortcutHints.ts @@ -21,10 +21,10 @@ export function getCommandShortcutHints( return { commands: [ - { trigger: '/init', effect: 'create AGENTS.md' }, + { trigger: '/config', effect: 'setup models and tools' }, { trigger: '/help', effect: 'open help' }, - { trigger: '/bash ', effect: 'run shell command' }, - { trigger: '/note ', effect: 'save note to AGENTS.md' }, + { trigger: '/model', effect: 'manage models' }, + { trigger: '/init', effect: 'create AGENTS.md' }, ], shortcuts: [ { trigger: `${modifier}+M`, effect: 'switch model' }, diff --git a/apps/cli/src/ui/utils/processUserInput.tsx b/apps/cli/src/ui/utils/processUserInput.tsx index b3b44fc25..55e3e4d02 100644 --- a/apps/cli/src/ui/utils/processUserInput.tsx +++ b/apps/cli/src/ui/utils/processUserInput.tsx @@ -18,7 +18,9 @@ import { switchCwdForResume } from '#cli-utils/switchCwdForResume' import { getMessagesForSlashCommand } from './slashCommands' import { coerceImageMediaType, + collectCommandNames, extractAssistantText, + formatUnknownSlashCommandMessage, } from './processUserInputHelpers' import { parseBuiltinInputCommand } from './builtinInputCommands' import type { SetForkConvoWithMessagesOnTheNextRender } from '#ui-ink/types/conversationReset' @@ -251,11 +253,21 @@ export async function processUserInput( ] } + // `//text` is the escape hatch for a literal leading slash. + if (inputTrimmedStart.startsWith('//')) { + return [createUserMessage(inputTrimmedStart.slice(1))] + } + // Check if it's a real command before processing if (!hasCommand(commandName, context.options?.commands ?? [])) { - // If not a real command, treat it as a regular user input - - return [createUserMessage(input)] + return [ + createAssistantMessage( + formatUnknownSlashCommandMessage( + commandName, + collectCommandNames(context.options?.commands ?? []), + ), + ), + ] } // Slash commands can carry per-command `allowedTools` constraints. These must be diff --git a/apps/cli/src/ui/utils/processUserInputHelpers.ts b/apps/cli/src/ui/utils/processUserInputHelpers.ts index 486c3bb3c..099a10c1f 100644 --- a/apps/cli/src/ui/utils/processUserInputHelpers.ts +++ b/apps/cli/src/ui/utils/processUserInputHelpers.ts @@ -23,6 +23,103 @@ function asRecord(value: unknown): Record | null { return value as Record } +export function collectCommandNames( + commands: ReadonlyArray<{ + userFacingName(): string + aliases?: string[] + }>, +): string[] { + const names = new Set() + for (const command of commands) { + const name = command.userFacingName().trim() + if (name) names.add(name) + for (const alias of command.aliases ?? []) { + const trimmed = alias.trim() + if (trimmed) names.add(trimmed) + } + } + return [...names] +} + +export function levenshteinDistance(a: string, b: string): number { + if (a === b) return 0 + if (a.length === 0) return b.length + if (b.length === 0) return a.length + + const prev = new Array(b.length + 1) + const curr = new Array(b.length + 1) + for (let j = 0; j <= b.length; j += 1) prev[j] = j + + for (let i = 1; i <= a.length; i += 1) { + curr[0] = i + for (let j = 1; j <= b.length; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + curr[j] = Math.min( + (prev[j] ?? 0) + 1, + (curr[j - 1] ?? 0) + 1, + (prev[j - 1] ?? 0) + cost, + ) + } + for (let j = 0; j <= b.length; j += 1) prev[j] = curr[j] ?? 0 + } + + return prev[b.length] ?? b.length +} + +export function suggestUnknownSlashCommands( + typed: string, + names: readonly string[], + limit = 3, +): string[] { + const query = typed.trim().toLowerCase() + if (!query || names.length === 0) return [] + + const maxDistance = + query.length < 3 ? -1 : Math.max(1, Math.floor(query.length / 2)) + const ranked = names + .map(name => { + const lower = name.toLowerCase() + if (lower === query) return { name, score: -1 } + if (lower.startsWith(query)) return { name, score: 0 } + if (lower.includes(query)) return { name, score: 1 } + if (maxDistance < 0) return { name, score: Number.POSITIVE_INFINITY } + return { name, score: levenshteinDistance(query, lower) } + }) + .filter(item => item.score <= Math.max(1, maxDistance)) + .sort( + (left, right) => + left.score - right.score || left.name.localeCompare(right.name), + ) + + const suggestions: string[] = [] + const seen = new Set() + for (const item of ranked) { + if (seen.has(item.name)) continue + seen.add(item.name) + suggestions.push(item.name) + if (suggestions.length >= limit) break + } + return suggestions +} + +export function formatUnknownSlashCommandMessage( + typed: string, + names: readonly string[], +): string { + const command = typed.trim().replace(/^\//, '') + const suggestions = suggestUnknownSlashCommands(command, names) + const lines = [`Unknown command: /${command}`] + if (suggestions.length > 0) { + lines.push( + `Did you mean: ${suggestions.map(name => `/${name}`).join(', ')}`, + ) + } + lines.push( + 'Type /help or press F1 for commands. Start a line with // to send a literal slash.', + ) + return lines.join('\n') +} + export function extractAssistantText(content: unknown): string { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' diff --git a/apps/cli/src/utils/completion/context.test.ts b/apps/cli/src/utils/completion/context.test.ts new file mode 100644 index 000000000..33e6121d6 --- /dev/null +++ b/apps/cli/src/utils/completion/context.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' +import { getCompletionContext } from './context' + +describe('getCompletionContext', () => { + test('classifies a leading slash token as a command', () => { + const context = getCompletionContext({ + input: '/hel', + cursorOffset: 4, + }) + expect(context).toEqual({ + type: 'command', + prefix: 'hel', + startPos: 0, + endPos: 4, + trigger: '/', + }) + }) + + test('classifies @agent as a mention', () => { + const context = getCompletionContext({ + input: 'see @run-ag', + cursorOffset: 11, + }) + expect(context).toEqual({ + type: 'agent', + prefix: 'run-ag', + startPos: 4, + endPos: 11, + trigger: '@', + }) + }) + + test('classifies @path tokens as file mentions', () => { + const atSrc = getCompletionContext({ + input: '@src/ma', + cursorOffset: 7, + }) + expect(atSrc).toEqual({ + type: 'file', + prefix: 'src/ma', + startPos: 0, + endPos: 7, + trigger: '@', + }) + + const atHome = getCompletionContext({ + input: '@~/.kode', + cursorOffset: 8, + }) + expect(atHome?.type).toBe('file') + expect(atHome?.trigger).toBe('@') + expect(atHome?.prefix).toBe('~/.kode') + }) + + test('does not treat mid-token @ as a mention trigger', () => { + const context = getCompletionContext({ + input: 'user@host', + cursorOffset: 9, + }) + expect(context).toEqual({ + type: 'file', + prefix: 'user@host', + startPos: 0, + endPos: 9, + trigger: null, + }) + }) + + test('keeps a mid-sentence email as a single file word', () => { + const input = 'ping admin@example.com please' + const at = input.indexOf('admin@example.com') + 'admin@example.com'.length + const context = getCompletionContext({ + input, + cursorOffset: at, + }) + expect(context?.type).toBe('file') + expect(context?.trigger).toBeNull() + expect(context?.prefix).toBe('admin@example.com') + }) +}) diff --git a/apps/cli/src/utils/completion/context.ts b/apps/cli/src/utils/completion/context.ts index e7a42a5e0..a27906f23 100644 --- a/apps/cli/src/utils/completion/context.ts +++ b/apps/cli/src/utils/completion/context.ts @@ -1,5 +1,18 @@ import type { CompletionContext } from './types' +function isAtMentionBoundary(input: string, atIndex: number): boolean { + return atIndex === 0 || /\s/.test(input[atIndex - 1]!) +} + +function isAtPathPrefix(content: string): boolean { + return ( + content.startsWith('.') || + content.startsWith('~') || + content.includes('/') || + content.includes('\\') + ) +} + export function getCompletionContext(args: { input: string cursorOffset: number @@ -15,9 +28,13 @@ export function getCompletionContext(args: { const char = input[start - 1]! if (/\s/.test(char)) break + // Only treat @ as a mention trigger at a token boundary. Mid-token + // addresses (user@host, emails) stay part of the current word. if (char === '@' && start < cursorOffset) { - start-- - break + if (isAtMentionBoundary(input, start - 1)) { + start-- + break + } } if (char === '/') { @@ -73,8 +90,10 @@ export function getCompletionContext(args: { if (word.startsWith('@')) { const content = word.slice(1) if (word.includes('@', 1)) return null + // @src/ and @~/foo are file mentions; @agent stays an agent mention. + const type = isAtPathPrefix(content) ? 'file' : 'agent' return { - type: 'agent', + type, prefix: content, startPos: start, endPos: cursorOffset, diff --git a/apps/cli/src/utils/completion/copy.test.ts b/apps/cli/src/utils/completion/copy.test.ts new file mode 100644 index 000000000..ab88f75fa --- /dev/null +++ b/apps/cli/src/utils/completion/copy.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test' +import { emptyDirectoryCompletionMessage } from './copy' + +describe('emptyDirectoryCompletionMessage', () => { + test('names the directory the user just opened', () => { + expect(emptyDirectoryCompletionMessage('docs/')).toBe('No files in docs/') + }) + + test('falls back when the path is blank', () => { + expect(emptyDirectoryCompletionMessage(' ')).toBe( + 'No files in this directory', + ) + }) +}) diff --git a/apps/cli/src/utils/completion/copy.ts b/apps/cli/src/utils/completion/copy.ts new file mode 100644 index 000000000..fd8c6d2d5 --- /dev/null +++ b/apps/cli/src/utils/completion/copy.ts @@ -0,0 +1,4 @@ +export function emptyDirectoryCompletionMessage(directory: string): string { + const label = directory.trim() || 'this directory' + return `No files in ${label}` +} diff --git a/apps/cli/src/utils/completion/fileSuggestions.test.ts b/apps/cli/src/utils/completion/fileSuggestions.test.ts index a36624f97..ac966a350 100644 --- a/apps/cli/src/utils/completion/fileSuggestions.test.ts +++ b/apps/cli/src/utils/completion/fileSuggestions.test.ts @@ -62,6 +62,43 @@ describe('generateFileSuggestions', () => { expect(singleChar.map(item => item.value)).toEqual(['dist.txt']) }) + test('treats an existing directory without a trailing slash as a name, not a listing', () => { + const cwd = makeTempDir() + mkdirSync(join(cwd, 'docs')) + writeFileSync(join(cwd, 'docs', 'readme.md'), '') + writeFileSync(join(cwd, 'docs.md'), '') + + const asName = generateFileSuggestions({ prefix: 'docs', cwd }) + expect(asName.map(item => item.value)).toEqual(['docs/', 'docs.md']) + expect(asName.map(item => item.value)).not.toContain('docs/readme.md') + + const asDir = generateFileSuggestions({ prefix: 'docs/', cwd }) + expect(asDir.map(item => item.value)).toEqual(['docs/readme.md']) + }) + + test('treats a backslash as a path separator when listing children', () => { + const cwd = makeTempDir() + mkdirSync(join(cwd, 'win')) + writeFileSync(join(cwd, 'win', 'a.ts'), '') + + const suggestions = generateFileSuggestions({ prefix: 'win\\', cwd }) + expect(suggestions.map(item => item.value)).toEqual(['win\\a.ts']) + }) + + test('expands ~ through the platform home directory, not the current workspace', () => { + const cwd = makeTempDir() + const marker = `kode-not-home-${Date.now()}` + mkdirSync(join(cwd, marker)) + + const suggestions = generateFileSuggestions({ prefix: '~/', cwd }) + expect(suggestions.map(item => item.value)).not.toContain(`${marker}/`) + for (const suggestion of suggestions) { + expect( + suggestion.value.startsWith('~/') || suggestion.value.startsWith('~\\'), + ).toBe(true) + } + }) + test('drops entries that neither prefix-match nor fuzzy-match in one pass', () => { const cwd = makeTempDir() writeFileSync(join(cwd, 'package.json'), '') diff --git a/apps/cli/src/utils/completion/fileSuggestions.ts b/apps/cli/src/utils/completion/fileSuggestions.ts index c051ae54e..89cc36b2b 100644 --- a/apps/cli/src/utils/completion/fileSuggestions.ts +++ b/apps/cli/src/utils/completion/fileSuggestions.ts @@ -1,8 +1,46 @@ import { existsSync, readdirSync, statSync, type Dirent } from 'fs' +import { homedir } from 'os' import { basename, dirname, join, resolve } from 'path' import { matchAdvanced } from './advancedFuzzyMatcher' import type { UnifiedSuggestion } from './types' +function lastPathSepIndex(userPath: string): number { + return Math.max(userPath.lastIndexOf('/'), userPath.lastIndexOf('\\')) +} + +function endsWithPathSep(userPath: string): boolean { + return userPath.endsWith('/') || userPath.endsWith('\\') +} + +function preferredSep(userPath: string): string { + return userPath.includes('\\') && !userPath.includes('/') ? '\\' : '/' +} + +function isAbsoluteUserPath(userPath: string): boolean { + if (userPath.startsWith('/') || userPath.startsWith('\\')) return true + return /^[A-Za-z]:[\\/]/.test(userPath) +} + +function expandUserPath(userPath: string, cwd: string): string { + if (userPath === '~') return homedir() + if (userPath.startsWith('~/') || userPath.startsWith('~\\')) { + return join(homedir(), userPath.slice(2)) + } + if (isAbsoluteUserPath(userPath)) return userPath + return resolve(cwd, userPath.replace(/\\/g, '/')) +} + +// List children only for a trailing separator or a root token. An existing +// directory name without a slash is a completion candidate, not an implicit cd. +function shouldListDirectoryContents( + prefix: string, + userPath: string, +): boolean { + if (prefix === '') return true + if (userPath === '.' || userPath === '~') return true + return endsWithPathSep(userPath) +} + function isDirectoryEntry(entry: Dirent, directory: string): boolean { if (entry.isDirectory()) return true if (!entry.isSymbolicLink()) return false @@ -23,25 +61,22 @@ export function generateFileSuggestions(args: { try { const userPath = prefix || '.' - const isAbsolutePath = userPath.startsWith('/') - const isHomePath = userPath.startsWith('~') - - let searchPath: string - if (isHomePath) { - searchPath = userPath.replace('~', process.env.HOME || '') - } else if (isAbsolutePath) { - searchPath = userPath - } else { - searchPath = resolve(cwd, userPath) + if ( + userPath.startsWith('~') && + userPath !== '~' && + !userPath.startsWith('~/') && + !userPath.startsWith('~\\') + ) { + return [] } - const endsWithSlash = userPath.endsWith('/') - const searchStat = existsSync(searchPath) ? statSync(searchPath) : null + const searchPath = expandUserPath(userPath, cwd) + const listContents = shouldListDirectoryContents(prefix, userPath) let searchDir: string let nameFilter: string - if (endsWithSlash || searchStat?.isDirectory()) { + if (listContents) { searchDir = searchPath nameFilter = '' } else { @@ -51,7 +86,10 @@ export function generateFileSuggestions(args: { if (!existsSync(searchDir)) return [] - const showHidden = nameFilter.startsWith('.') || userPath.includes('/.') + const showHidden = + nameFilter.startsWith('.') || + userPath.includes('/.') || + userPath.includes('\\.') const lowerNameFilter = nameFilter.toLowerCase() const useFuzzy = lowerNameFilter.length >= 2 // Single pass: compute the expensive fuzzy match once per entry instead of @@ -116,27 +154,25 @@ export function generateFileSuggestions(args: { const entryName = entry.name const icon = isDir ? '📁' : '📄' + const sep = preferredSep(userPath) + const sepIndex = lastPathSepIndex(userPath) let value: string - if (userPath.includes('/')) { - if (endsWithSlash) { - value = userPath + entryName + (isDir ? '/' : '') - } else if (searchStat?.isDirectory()) { - value = userPath + '/' + entryName + (isDir ? '/' : '') + if (sepIndex !== -1) { + if (endsWithPathSep(userPath)) { + value = userPath + entryName + (isDir ? sep : '') + } else if (listContents) { + value = userPath + sep + entryName + (isDir ? sep : '') } else { - const userDir = userPath.includes('/') - ? userPath.substring(0, userPath.lastIndexOf('/')) - : '' + const userDir = userPath.slice(0, sepIndex) value = userDir - ? userDir + '/' + entryName + (isDir ? '/' : '') - : entryName + (isDir ? '/' : '') + ? userDir + sep + entryName + (isDir ? sep : '') + : entryName + (isDir ? sep : '') } + } else if (listContents) { + value = userPath + sep + entryName + (isDir ? sep : '') } else { - if (searchStat?.isDirectory()) { - value = userPath + '/' + entryName + (isDir ? '/' : '') - } else { - value = entryName + (isDir ? '/' : '') - } + value = entryName + (isDir ? sep : '') } return { diff --git a/apps/cli/src/utils/completion/generateSuggestions.test.ts b/apps/cli/src/utils/completion/generateSuggestions.test.ts new file mode 100644 index 000000000..1a8ad82aa --- /dev/null +++ b/apps/cli/src/utils/completion/generateSuggestions.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + __preferFilesOverMentionsForTests, + generateSuggestionsForContext, +} from './generateSuggestions' +import type { UnifiedSuggestion } from './types' + +const tempDirs: string[] = [] + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'kode-completion-suggestions-')) + tempDirs.push(dir) + return dir +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }) + } +}) + +const exploreAgent: UnifiedSuggestion = { + value: 'run-agent-explore', + displayValue: '👤 run-agent-explore', + type: 'agent', + score: 85, +} + +const srcAgent: UnifiedSuggestion = { + value: 'run-agent-src', + displayValue: '👤 run-agent-src', + type: 'agent', + score: 85, +} + +describe('__preferFilesOverMentionsForTests', () => { + test('prefers files when the prefix hits a file and no agent', () => { + expect( + __preferFilesOverMentionsForTests({ + prefix: 'src', + mentionSuggestions: [exploreAgent], + fileSuggestions: [ + { value: 'src/', displayValue: 'src/', type: 'file', score: 80 }, + ], + }), + ).toBe(true) + }) + + test('keeps mentions first when an agent name matches the prefix', () => { + expect( + __preferFilesOverMentionsForTests({ + prefix: 'src', + mentionSuggestions: [srcAgent], + fileSuggestions: [ + { value: 'src/', displayValue: 'src/', type: 'file', score: 80 }, + ], + }), + ).toBe(false) + }) + + test('keeps mentions first for an empty @ prefix', () => { + expect( + __preferFilesOverMentionsForTests({ + prefix: '', + mentionSuggestions: [exploreAgent], + fileSuggestions: [ + { value: 'src/', displayValue: 'src/', type: 'file', score: 80 }, + ], + }), + ).toBe(false) + }) +}) + +describe('generateSuggestionsForContext @ ranking', () => { + test('ranks a cwd folder above an unrelated agent for @src', () => { + const cwd = makeTempDir() + mkdirSync(join(cwd, 'src')) + + const suggestions = generateSuggestionsForContext({ + context: { + type: 'agent', + prefix: 'src', + startPos: 0, + endPos: 4, + trigger: '@', + }, + commands: [], + agentSuggestions: [exploreAgent], + modelSuggestions: [], + systemCommands: [], + isLoadingCommands: false, + cwd, + }) + + expect(suggestions[0]?.type).toBe('file') + expect(suggestions[0]?.value).toBe('src/') + }) + + test('ranks a prefix-matching agent above the same-named folder', () => { + const cwd = makeTempDir() + mkdirSync(join(cwd, 'src')) + + const suggestions = generateSuggestionsForContext({ + context: { + type: 'agent', + prefix: 'src', + startPos: 0, + endPos: 4, + trigger: '@', + }, + commands: [], + agentSuggestions: [srcAgent], + modelSuggestions: [], + systemCommands: [], + isLoadingCommands: false, + cwd, + }) + + expect(suggestions[0]?.type).toBe('agent') + expect(suggestions[0]?.value).toBe('run-agent-src') + }) +}) diff --git a/apps/cli/src/utils/completion/generateSuggestions.ts b/apps/cli/src/utils/completion/generateSuggestions.ts index 3d5614eee..1fb262610 100644 --- a/apps/cli/src/utils/completion/generateSuggestions.ts +++ b/apps/cli/src/utils/completion/generateSuggestions.ts @@ -9,6 +9,47 @@ import { homedir } from 'os' import { join } from 'path' import { LEGACY_CONFIG_DIRNAME } from '#core/compat/legacyPaths' +function suggestionBaseName(value: string): string { + const trimmed = value.replace(/[\\/]+$/, '') + const sep = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + return sep === -1 ? trimmed : trimmed.slice(sep + 1) +} + +function isPrefixHit(value: string, prefix: string): boolean { + const lower = prefix.toLowerCase() + if (!lower) return true + return ( + value.toLowerCase().startsWith(lower) || + suggestionBaseName(value).toLowerCase().startsWith(lower) + ) +} + +function isMentionPrefixHit( + suggestion: UnifiedSuggestion, + prefix: string, +): boolean { + if (isPrefixHit(suggestion.value, prefix)) return true + return suggestion.value + .toLowerCase() + .startsWith(`run-agent-${prefix.toLowerCase()}`) +} + +// @src with a cwd file/dir prefix, and no agent/model prefix, should surface +// the file first. @run-agent-* and empty @ stay mention-first. +export function __preferFilesOverMentionsForTests(args: { + prefix: string + mentionSuggestions: UnifiedSuggestion[] + fileSuggestions: UnifiedSuggestion[] +}): boolean { + if (!args.prefix) return false + if ( + args.mentionSuggestions.some(item => isMentionPrefixHit(item, args.prefix)) + ) { + return false + } + return args.fileSuggestions.some(item => isPrefixHit(item.value, args.prefix)) +} + function generateSpecialFileRootSuggestions(args: { prefix: string cwd: string @@ -112,6 +153,11 @@ export function generateSuggestionsForContext(args: { prefix: context.prefix, cwd, }) + const preferFiles = __preferFilesOverMentionsForTests({ + prefix: context.prefix, + mentionSuggestions, + fileSuggestions, + }) const weightedSuggestions = [ ...mentionLoadingSuggestions.map(s => ({ @@ -120,11 +166,11 @@ export function generateSuggestionsForContext(args: { })), ...mentionSuggestions.map(s => ({ ...s, - weightedScore: s.score + 150, + weightedScore: s.score + (preferFiles ? 10 : 150), })), ...fileSuggestions.map(s => ({ ...s, - weightedScore: s.score + 10, + weightedScore: s.score + (preferFiles ? 150 : 10), })), ] diff --git a/bun.lock b/bun.lock index 1deeadc10..dd26ba00b 100644 --- a/bun.lock +++ b/bun.lock @@ -50,7 +50,7 @@ "react-reconciler": "^0.33.0", "semver": "^7.8.2", "sharp": "0.35.3", - "shell-quote": "^1.9.0", + "shell-quote": "1.10.0", "string-width": "8.2.1", "strip-ansi": "^7.2.0", "tsx": "^4.23.0", @@ -98,7 +98,7 @@ "lucide-react": "1.23.0", "next-themes": "^0.4.6", "oxlint": "1.77.0", - "postcss": "^8.5.26", + "postcss": "8.5.26", "prettier": "3.9.4", "react-devtools-core": "^7.0.1", "react-dom": "19.2.7", diff --git a/package.json b/package.json index bde99fa86..54bd89b80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shareai-lab/kode", - "version": "2.2.1", + "version": "2.2.2", "packageManager": "bun@1.3.14", "workspaces": [ "apps/*", @@ -160,7 +160,7 @@ "react": "19.2.7", "react-reconciler": "^0.33.0", "semver": "^7.8.2", - "shell-quote": "^1.9.0", + "shell-quote": "1.10.0", "string-width": "8.2.1", "strip-ansi": "^7.2.0", "tsx": "^4.23.0", @@ -211,7 +211,7 @@ "esbuild": "0.28.1", "lucide-react": "1.23.0", "next-themes": "^0.4.6", - "postcss": "^8.5.26", + "postcss": "8.5.26", "prettier": "3.9.4", "react-devtools-core": "^7.0.1", "react-dom": "19.2.7", diff --git a/packages/core/src/test/e2e/inkTestHarness.tsx b/packages/core/src/test/e2e/inkTestHarness.tsx index abb30509d..0eddf64e3 100644 --- a/packages/core/src/test/e2e/inkTestHarness.tsx +++ b/packages/core/src/test/e2e/inkTestHarness.tsx @@ -17,6 +17,11 @@ export type InkTestHarness = { clearOutput: () => void getOutput: () => string wait: (ms: number) => Promise + waitFor: ( + predicate: (output: string) => boolean, + timeoutMs?: number, + ) => Promise + typeText: (text: string, interCharacterDelayMs?: number) => Promise } class TestErrorBoundary extends React.Component< @@ -73,6 +78,20 @@ export function createInkTestHarness( exitOnCtrlC: false, }) + const waitForOutput = async ( + predicate: (output: string) => boolean, + timeoutMs = 1_000, + ): Promise => { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate(stripAnsi(rawOutput))) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('Timed out waiting for Ink output') + } + const wait = (ms: number): Promise => + new Promise(resolve => setTimeout(resolve, ms)) + return { stdin, stdout, @@ -82,7 +101,14 @@ export function createInkTestHarness( rawOutput = '' }, getOutput: () => stripAnsi(rawOutput), - wait: async ms => new Promise(resolve => setTimeout(resolve, ms)), + wait, + waitFor: waitForOutput, + typeText: async (text, interCharacterDelayMs = 25) => { + for (const character of text) { + stdin.write(character) + await wait(interCharacterDelayMs) + } + }, } } diff --git a/packages/core/src/test/e2e/tui-interactions.completionNavigation.test.tsx b/packages/core/src/test/e2e/tui-interactions.completionNavigation.test.tsx index d686f481e..0655645e5 100644 --- a/packages/core/src/test/e2e/tui-interactions.completionNavigation.test.tsx +++ b/packages/core/src/test/e2e/tui-interactions.completionNavigation.test.tsx @@ -250,6 +250,182 @@ function TabCompletionHarness({ ) } +const filePathContext: CompletionContext = { + type: 'file', + prefix: 'src/ma', + startPos: 6, + endPos: 12, +} + +const filePathSuggestions: UnifiedSuggestion[] = [ + { + value: 'src/main.ts', + displayValue: 'src/main.ts', + type: 'file', + score: 1, + }, +] + +const fileTabSuggestions: UnifiedSuggestion[] = [ + { + value: 'src/main.ts', + displayValue: 'src/main.ts', + type: 'file', + score: 2, + }, + { + value: 'src/math.ts', + displayValue: 'src/math.ts', + type: 'file', + score: 1, + }, +] + +const fileTabContext: CompletionContext = { + type: 'file', + prefix: 'src/ma', + startPos: 0, + endPos: 6, + trigger: null, +} + +function FileTabCycleEscHarness(): React.ReactNode { + const [input, setInput] = useState('src/ma') + const [cursorOffset, setCursorOffset] = useState(6) + const [state, setState] = useState(() => makeState({})) + const { completeWith } = useCompletionActions({ + input, + onInputChange: setInput, + setCursorOffset, + }) + + const resetCompletion = useCallback(() => { + setState(prev => ({ + ...prev, + suggestions: [], + selectedIndex: 0, + isActive: false, + context: null, + preview: null, + emptyDirMessage: '', + })) + }, []) + + const updateState = useCallback((updates: Partial) => { + setState(prev => ({ ...prev, ...updates })) + }, []) + + const activateCompletion = useCallback( + (suggestions: UnifiedSuggestion[], context: CompletionContext) => { + setState(prev => ({ + ...prev, + suggestions, + selectedIndex: 0, + isActive: true, + context, + preview: null, + })) + }, + [], + ) + + useUnifiedCompletionTabKey({ + input, + state, + getWordAtCursor: () => fileTabContext, + generateSuggestions: () => fileTabSuggestions, + completeWith, + activateCompletion, + resetCompletion, + updateState, + onInputChange: setInput, + setCursorOffset, + isEnabled: true, + }) + + useUnifiedCompletionNavigationKeys({ + input, + state, + resetCompletion, + updateState, + generateSuggestions: () => fileTabSuggestions, + completeWith, + activateCompletion, + onInputChange: setInput, + setCursorOffset, + isEnabled: true, + }) + + return ( + {`INPUT:${input}|ORIG:${state.preview?.originalInput ?? ''}|ACTIVE:${state.isActive}`} + ) +} + +function FileEnterCompletionHarness(): React.ReactNode { + const [input, setInput] = useState('check src/ma') + const [cursorOffset, setCursorOffset] = useState(12) + const [state, setState] = useState(() => + makeState({ + suggestions: filePathSuggestions, + selectedIndex: 0, + isActive: true, + context: filePathContext, + }), + ) + const { completeWith } = useCompletionActions({ + input, + onInputChange: setInput, + setCursorOffset, + }) + + const resetCompletion = useCallback(() => { + setState(prev => ({ + ...prev, + suggestions: [], + selectedIndex: 0, + isActive: false, + context: null, + preview: null, + emptyDirMessage: '', + })) + }, []) + + const updateState = useCallback((updates: Partial) => { + setState(prev => ({ ...prev, ...updates })) + }, []) + + const activateCompletion = useCallback( + (suggestions: UnifiedSuggestion[], context: CompletionContext) => { + setState(prev => ({ + ...prev, + suggestions, + selectedIndex: 0, + isActive: true, + context, + preview: null, + })) + }, + [], + ) + + useUnifiedCompletionNavigationKeys({ + input, + state, + resetCompletion, + updateState, + generateSuggestions: () => filePathSuggestions, + completeWith, + activateCompletion, + onInputChange: setInput, + setCursorOffset, + isEnabled: true, + }) + + return ( + {`INPUT:${input}|CURSOR:${cursorOffset}|ACTIVE:${state.isActive}`} + ) +} + describe('TUI E2E regression (Ink render): completion navigation', () => { test('clears delayed empty-directory updates when completion unmounts', async () => { const updates: Array> = [] @@ -264,12 +440,12 @@ describe('TUI E2E regression (Ink render): completion navigation', () => { h.stdin.write('\u001b[C') await h.wait(100) - expect(updates).toEqual([{ emptyDirMessage: 'Directory is empty: empty/' }]) + expect(updates).toEqual([{ emptyDirMessage: 'No files in empty/' }]) h.unmount() await h.wait(3200) - expect(updates).toEqual([{ emptyDirMessage: 'Directory is empty: empty/' }]) + expect(updates).toEqual([{ emptyDirMessage: 'No files in empty/' }]) }) test('does not reopen directory completion after the user keeps typing', async () => { @@ -307,7 +483,7 @@ describe('TUI E2E regression (Ink render): completion navigation', () => { expect(h.getOutput()).toContain('INPUT:/second |CURSOR:8|ACTIVE:false') }) - test('Tab completes the first slash command before completion activates', async () => { + test('Tab opens the command list when more than one command matches', async () => { const h = createInkTestHarness( @@ -322,6 +498,52 @@ describe('TUI E2E regression (Ink render): completion navigation', () => { h.stdin.write('\t') await h.wait(50) - expect(h.getOutput()).toContain('INPUT:/first |CURSOR:7|ACTIVE:false') + expect(h.getOutput()).toContain('INPUT:/se|CURSOR:3|ACTIVE:true') + }) + + test('Tab cycling keeps the original input so Esc can restore it', async () => { + const h = createInkTestHarness( + + + , + ) + harnessManager.track(h) + + await h.wait(25) + expect(h.getOutput()).toContain('INPUT:src/ma|ORIG:|ACTIVE:false') + + h.clearOutput() + h.stdin.write('\t') + await h.wait(50) + expect(h.getOutput()).toContain('INPUT:src/main.ts|ORIG:src/ma|ACTIVE:true') + + h.clearOutput() + h.stdin.write('\t') + await h.wait(50) + expect(h.getOutput()).toContain('INPUT:src/math.ts|ORIG:src/ma|ACTIVE:true') + + h.clearOutput() + h.stdin.write('\u001b') + // Lone ESC is flushed after ESC_TIMEOUT (50ms) in KeypressContext. + await h.wait(150) + expect(h.getOutput()).toContain('INPUT:src/ma|ORIG:|ACTIVE:false') + }) + + test('Enter closes file completion without changing the typed path', async () => { + const h = createInkTestHarness( + + + , + ) + harnessManager.track(h) + + await h.wait(25) + expect(h.getOutput()).toContain('INPUT:check src/ma|CURSOR:12|ACTIVE:true') + + h.clearOutput() + h.stdin.write('\r') + await h.wait(50) + + expect(h.getOutput()).toContain('INPUT:check src/ma|CURSOR:12|ACTIVE:false') }) }) diff --git a/packages/core/src/test/e2e/tui-interactions.misc.test.tsx b/packages/core/src/test/e2e/tui-interactions.misc.test.tsx index d8751970b..7fed20fd4 100644 --- a/packages/core/src/test/e2e/tui-interactions.misc.test.tsx +++ b/packages/core/src/test/e2e/tui-interactions.misc.test.tsx @@ -138,10 +138,7 @@ describe('TUI E2E regression (Ink render): Misc', () => { 'the Other text field to receive focus', ) - for (const ch of 'threejs') { - h.stdin.write(ch) - await h.wait(20) - } + await h.typeText('threejs') h.stdin.write('\r') await h.wait(25) diff --git a/packages/core/src/test/e2e/tui-interactions.promptInput.test.tsx b/packages/core/src/test/e2e/tui-interactions.promptInput.test.tsx index 925a51bac..f653dfd41 100644 --- a/packages/core/src/test/e2e/tui-interactions.promptInput.test.tsx +++ b/packages/core/src/test/e2e/tui-interactions.promptInput.test.tsx @@ -911,7 +911,10 @@ describe('TUI E2E regression (Ink render): PromptInput', () => { h.stdin.write('\t') await h.wait(75) expect(h.getOutput()).not.toContain('/agents') - await waitForOutput(h, 'PROCESSED:[\"/a\"]', 1_500) + expect(h.getOutput()).toContain('/a') + await waitForOutput(h, 'LOADING:false', 1_500) + expect(h.getOutput()).not.toContain('/agents') + expect(h.getOutput()).toContain('PROCESSED:[""]') }) test('statusline renders when configured', async () => { diff --git a/packages/core/src/test/e2e/tui-interactions.sessionMessageScreen.test.tsx b/packages/core/src/test/e2e/tui-interactions.sessionMessageScreen.test.tsx index ccc48d5dd..37911e9f9 100644 --- a/packages/core/src/test/e2e/tui-interactions.sessionMessageScreen.test.tsx +++ b/packages/core/src/test/e2e/tui-interactions.sessionMessageScreen.test.tsx @@ -57,7 +57,7 @@ describe('TUI E2E: SessionMessageScreen', () => { rmSync(workspace, { recursive: true, force: true }) }) - test('selects a session, composes, sends, and shows threaded history', async () => { + test('selects a session, composes, sends, and persists threaded history', async () => { const h = createInkTestHarness( { expect(h.getOutput()).toContain('Security reviewer') h.stdin.write('\r') - await h.wait(40) + await h.waitFor(output => + output.includes('New message to Security reviewer'), + ) expect(h.getOutput()).toContain('New message to Security reviewer') - h.stdin.write('Please verify the cancellation race.') + // The heading is rendered one frame before the text input subscribes to keypresses. await h.wait(100) + await h.typeText('Please verify the cancellation race.', 50) + await h.wait(200) h.stdin.write('\r') - await h.wait(120) + await h.waitFor(output => output.includes('Queued'), 5_000) expect(h.getOutput()).toContain('Queued') - expect(h.getOutput()).toContain('Please verify the cancellation race.') expect( (await peekSessionMessages({ cwd: workspace, sessionId: TARGET }))[0] ?.body, ).toBe('Please verify the cancellation race.') - }) + }, 20_000) }) diff --git a/packages/core/src/test/integration/daemon-client.test.ts b/packages/core/src/test/integration/daemon-client.test.ts index a3b005bcd..82e49977d 100644 --- a/packages/core/src/test/integration/daemon-client.test.ts +++ b/packages/core/src/test/integration/daemon-client.test.ts @@ -1,12 +1,17 @@ import { describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { createKodeDaemonClient } from '#daemon/client' import { startKodeDaemon } from '#daemon/server' describe('daemon client SDK', () => { test('connects, sends prompt, and yields AgentEvents (echo)', async () => { + const timeoutMs = 15_000 + const workspace = mkdtempSync(join(tmpdir(), 'kode-daemon-client-')) const daemon = await startKodeDaemon({ - cwd: process.cwd(), + cwd: workspace, port: 0, echo: true, }) @@ -14,7 +19,7 @@ describe('daemon client SDK', () => { const client = createKodeDaemonClient({ url: daemon.url }) try { - await client.connect({ timeoutMs: 5_000 }) + await client.connect({ timeoutMs }) client.sendPrompt('hello') @@ -27,7 +32,7 @@ describe('daemon client SDK', () => { } })(), new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), 5_000), + setTimeout(() => reject(new Error('timeout')), timeoutMs), ), ]) @@ -50,6 +55,7 @@ describe('daemon client SDK', () => { /* no-op */ } daemon.stop() + rmSync(workspace, { recursive: true, force: true }) } - }, 20_000) + }, 45_000) }) diff --git a/packages/core/src/test/integration/daemon-fs-path-security.test.ts b/packages/core/src/test/integration/daemon-fs-path-security.test.ts index 03893f685..c6e5913b1 100644 --- a/packages/core/src/test/integration/daemon-fs-path-security.test.ts +++ b/packages/core/src/test/integration/daemon-fs-path-security.test.ts @@ -140,5 +140,5 @@ describe('daemon fs path security', () => { daemon.stop() rmSync(projectDir, { recursive: true, force: true }) } - }, 25_000) + }, 45_000) }) diff --git a/packages/core/src/test/integration/daemon-smoke.test.ts b/packages/core/src/test/integration/daemon-smoke.test.ts index 3c72b11ec..2c18a5979 100644 --- a/packages/core/src/test/integration/daemon-smoke.test.ts +++ b/packages/core/src/test/integration/daemon-smoke.test.ts @@ -122,8 +122,9 @@ async function waitForInit(events: AnyEvent[]): Promise { describe('daemon (Bun HTTP+WS)', () => { test('health + token gate + ws prompt (echo)', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'kode-daemon-smoke-')) const daemon = await startKodeDaemon({ - cwd: process.cwd(), + cwd: workspace, port: 0, echo: true, }) @@ -217,8 +218,9 @@ describe('daemon (Bun HTTP+WS)', () => { await closeWs(ws) } finally { daemon.stop() + rmSync(workspace, { recursive: true, force: true }) } - }, 20_000) + }, 45_000) test('reattaches to a daemon session after websocket disconnect', async () => { const daemon = await startKodeDaemon({ diff --git a/packages/core/src/test/integration/webui-autodetect.test.ts b/packages/core/src/test/integration/webui-autodetect.test.ts index ba9a2646c..f169cf96e 100644 --- a/packages/core/src/test/integration/webui-autodetect.test.ts +++ b/packages/core/src/test/integration/webui-autodetect.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { startKodeDaemon } from '#daemon/server' function ensureWebuiBuilt(): void { - const index = join(process.cwd(), 'dist', 'webui', 'index.html') + const index = join(process.cwd(), 'apps', 'server', 'static', 'index.html') if (existsSync(index)) return const res = spawnSync(process.execPath, ['run', 'build:web'], { diff --git a/packages/core/src/test/unit/process-user-input-helpers.test.ts b/packages/core/src/test/unit/process-user-input-helpers.test.ts new file mode 100644 index 000000000..073555941 --- /dev/null +++ b/packages/core/src/test/unit/process-user-input-helpers.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { + collectCommandNames, + formatUnknownSlashCommandMessage, + levenshteinDistance, + suggestUnknownSlashCommands, +} from '#ui-ink/utils/processUserInputHelpers' + +describe('unknown slash command helpers', () => { + test('ranks prefix and one-edit typos ahead of unrelated names', () => { + const names = collectCommandNames([ + { userFacingName: () => 'help', aliases: ['h'] }, + { userFacingName: () => 'model' }, + { userFacingName: () => 'mcp' }, + ]) + + expect(names).toEqual(['help', 'h', 'model', 'mcp']) + expect(suggestUnknownSlashCommands('hepl', names)).toEqual(['help']) + expect(suggestUnknownSlashCommands('mo', names)).toEqual(['model']) + expect(levenshteinDistance('hepl', 'help')).toBe(2) + }) + + test('formats a local unknown-command message with suggestions', () => { + const message = formatUnknownSlashCommandMessage('hepl', ['help', 'model']) + expect(message).toContain('Unknown command: /hepl') + expect(message).toContain('/help') + expect(message).toContain('//') + }) +}) diff --git a/packages/core/src/test/unit/prompt-command-input.test.ts b/packages/core/src/test/unit/prompt-command-input.test.ts index b63d051b6..90144f651 100644 --- a/packages/core/src/test/unit/prompt-command-input.test.ts +++ b/packages/core/src/test/unit/prompt-command-input.test.ts @@ -96,6 +96,60 @@ describe('prompt command input', () => { expect(getCwd()).toBe(join(projectDir, 'foo')) }) + test('unknown slash commands stay local and suggest nearby names', async () => { + const context = makeContext() + context.options.commands = [ + { + type: 'local', + name: 'help', + description: 'Show help', + isEnabled: true, + isHidden: false, + userFacingName: () => 'help', + aliases: ['h'], + call: async () => '', + }, + ] + + const messages = await processUserInput( + '/hepl', + 'prompt', + () => {}, + context, + null, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]?.type).toBe('assistant') + const text = extractAssistantText(messages) + expect(text).toContain('Unknown command: /hepl') + expect(text).toContain('/help') + expect(text).toContain('//') + expect(text).not.toContain('EISDIR') + }) + + test('// sends a literal slash line to the model', async () => { + const messages = await processUserInput( + '//not-a-command', + 'prompt', + () => {}, + makeContext(), + null, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]?.type).toBe('user') + const content = (messages[0] as { message?: { content?: unknown } }).message + ?.content + const text = + typeof content === 'string' + ? content + : Array.isArray(content) + ? content.map((block: { text?: string }) => block.text ?? '').join('') + : '' + expect(text).toBe('/not-a-command') + }) + test('/bash is plain text when slash commands are disabled', async () => { const messages = await processUserInput( '/bash cd foo', diff --git a/packages/core/src/test/unit/promptinput-mode-specs.test.ts b/packages/core/src/test/unit/promptinput-mode-specs.test.ts index bb1f1cd51..f34cebd13 100644 --- a/packages/core/src/test/unit/promptinput-mode-specs.test.ts +++ b/packages/core/src/test/unit/promptinput-mode-specs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + applyTypedPromptModePrefix, getPromptModeForTypedPrefix, getPromptModePrefix, getPromptModeSpec, @@ -32,6 +33,22 @@ describe('PromptInput mode specs', () => { expect(getPromptModeForTypedPrefix({ mode: 'bash', value: '&' })).toBeNull() }) + test('keeps the rest of a pasted background command', () => { + expect(applyTypedPromptModePrefix({ mode: 'prompt', value: '&' })).toEqual({ + mode: 'background', + value: '', + }) + expect( + applyTypedPromptModePrefix({ mode: 'prompt', value: '&ls -la' }), + ).toEqual({ + mode: 'background', + value: 'ls -la', + }) + expect( + applyTypedPromptModePrefix({ mode: 'prompt', value: 'ls -la' }), + ).toBeNull() + }) + test('centralizes mode transition rules', () => { expect(isShellPromptMode('bash')).toBe(true) expect(isShellPromptMode('background')).toBe(true) diff --git a/packages/core/src/test/unit/promptinput-status-line.test.ts b/packages/core/src/test/unit/promptinput-status-line.test.ts index 397763395..8099e12fc 100644 --- a/packages/core/src/test/unit/promptinput-status-line.test.ts +++ b/packages/core/src/test/unit/promptinput-status-line.test.ts @@ -1,10 +1,21 @@ import { describe, expect, test } from 'bun:test' import { buildPromptInputStatusLine, + formatCancelledFollowUpsMessage, getInputModeDisplay, } from '#ui-ink/components/PromptInput/inputModeDisplay' describe('PromptInput status line', () => { + test('names cancelled follow-ups instead of dropping them silently', () => { + expect(formatCancelledFollowUpsMessage(0)).toBe('Cancelled') + expect(formatCancelledFollowUpsMessage(1)).toBe( + 'Cancelled · discarded 1 follow-up', + ) + expect(formatCancelledFollowUpsMessage(3)).toBe( + 'Cancelled · discarded 3 follow-ups', + ) + }) + test('keeps chat status focused on mode and tool policy', () => { const display = getInputModeDisplay('prompt') @@ -40,6 +51,7 @@ describe('PromptInput status line', () => { expect(text).toContain('Tab queue') expect(text).toContain('pending 1') expect(text).toContain('queued 2') + expect(text).toContain('Alt+Up edit') expect(text).not.toContain('Enter send') expect(text).not.toContain('Auto-accept edits') }) @@ -56,4 +68,34 @@ describe('PromptInput status line', () => { expect(text).toContain('Tools Edit (shift+tab)') }) + + test('offers Alt+Up edit for a pending follow-up with no Tab queue', () => { + const text = buildPromptInputStatusLine({ + mode: 'prompt', + permissionMode: 'cautious', + modeCycleShortcutText: 'shift+tab', + isLoading: true, + pendingPromptCount: 1, + queuedPromptCount: 0, + }) + + expect(text).toContain('pending 1') + expect(text).toContain('Alt+Up edit') + expect(text).not.toContain('queued') + }) + + test('surfaces stash restore only while the input is empty', () => { + const text = buildPromptInputStatusLine({ + mode: 'prompt', + permissionMode: 'cautious', + modeCycleShortcutText: 'shift+tab', + isLoading: false, + pendingPromptCount: 0, + queuedPromptCount: 0, + stashRestorable: true, + }) + + expect(text).toContain('Ctrl+S restore') + expect(text).not.toContain('Enter send') + }) }) diff --git a/packages/core/src/test/unit/request-status.test.ts b/packages/core/src/test/unit/request-status.test.ts index 90c41489b..3856c281e 100644 --- a/packages/core/src/test/unit/request-status.test.ts +++ b/packages/core/src/test/unit/request-status.test.ts @@ -7,6 +7,7 @@ import { getRequestStatusLabel, getRequestStatusPhaseLabel, getRequestStatusTiming, + shouldShowRequestStatusPhase, getRequestStatusTokenDisplay, REQUEST_STATUS_ESC_CANCEL_HINT, setRequestStatus, @@ -141,7 +142,7 @@ describe('shared request status display helpers', () => { { ...base, kind: 'waiting' }, FIRST_RESPONSE_WARNING_SECONDS + 1, ), - ).toBe('Waiting for model response · still waiting') + ).toBe('Waiting for model response') expect(getRequestStatusLabel({ ...base, kind: 'thinking' }, 1)).toBe( 'Thinking', ) @@ -219,6 +220,24 @@ describe('shared request status display helpers', () => { }) test('exposes the shared cancel affordance text', () => { - expect(REQUEST_STATUS_ESC_CANCEL_HINT).toBe('(Esc cancel)') + expect(REQUEST_STATUS_ESC_CANCEL_HINT).toBe('(Esc to cancel)') + }) + + test('hides a phase chip that only restates the waiting label', () => { + const waiting: RequestStatus = { + kind: 'waiting', + updatedAt: 0, + startedAt: 0, + phaseStartedAt: 0, + } + expect(shouldShowRequestStatusPhase(waiting, 15_000)).toBe(false) + + const thinkingAfterWait: RequestStatus = { + kind: 'thinking', + updatedAt: 20_000, + startedAt: 0, + phaseStartedAt: 18_000, + } + expect(shouldShowRequestStatusPhase(thinkingAfterWait, 20_000)).toBe(true) }) }) diff --git a/packages/runtime/src/requestStatus.ts b/packages/runtime/src/requestStatus.ts index cad3a191f..1fda33f57 100644 --- a/packages/runtime/src/requestStatus.ts +++ b/packages/runtime/src/requestStatus.ts @@ -188,7 +188,7 @@ export function subscribeRequestStatus( export const FIRST_RESPONSE_WARNING_SECONDS = 15 /** Shared "cancel" affordance text shown next to a running request. */ -export const REQUEST_STATUS_ESC_CANCEL_HINT = '(Esc cancel)' +export const REQUEST_STATUS_ESC_CANCEL_HINT = '(Esc to cancel)' /** Formats a whole number of seconds as "5s", "2m 3s", "1h 2m 3s". */ export function formatRequestStatusDuration(seconds: number): string { @@ -225,9 +225,7 @@ export function getRequestStatusLabel( case 'waiting': { const detail = status.detail?.trim() if (!detail) { - return elapsedSeconds >= FIRST_RESPONSE_WARNING_SECONDS - ? 'Waiting for model response · still waiting' - : 'Waiting for model response' + return 'Waiting for model response' } return elapsedSeconds >= FIRST_RESPONSE_WARNING_SECONDS ? `${detail} · waiting for first model response` @@ -287,3 +285,18 @@ export function getRequestStatusPhaseLabel( return '' } } + +/** Hide the phase chip when it only restates the main label or matches total time. */ +export function shouldShowRequestStatusPhase( + status: RequestStatus, + now: number, +): boolean { + if (status.kind === 'idle' || status.kind === 'waiting') return false + + const timing = getRequestStatusTiming(status, now) + const phaseMs = + status.kind === 'thinking' + ? timing.thinkingDurationMs + : timing.phaseDurationMs + return phaseMs + 1000 < timing.requestDurationMs +} diff --git a/scripts/run-workspace-tests.mjs b/scripts/run-workspace-tests.mjs index 2af15c1c2..f0ec67f72 100644 --- a/scripts/run-workspace-tests.mjs +++ b/scripts/run-workspace-tests.mjs @@ -106,6 +106,26 @@ const testFiles = if (testFiles.length === 0) throw new Error('No workspace test files found') +const webUiTestFiles = new Set([ + 'packages/core/src/test/integration/webui-autodetect.test.ts', + 'packages/core/src/test/integration/webui-static.test.ts', +]) + +if (testFiles.some(file => webUiTestFiles.has(file))) { + process.stdout.write('Building shared WebUI test artifact\n') + const webBuild = Bun.spawn([process.execPath, 'run', 'build:web'], { + cwd: repoRoot, + env: process.env, + stdin: 'ignore', + stdout: 'inherit', + stderr: 'inherit', + }) + const exitCode = await webBuild.exited + if (exitCode !== 0) { + throw new Error(`Shared WebUI build failed with exit code ${exitCode}`) + } +} + const isCI = isEnabledEnvironmentFlag(process.env.CI) || isEnabledEnvironmentFlag(process.env.CONTINUOUS_INTEGRATION)