From dd5ead1007b188a806c5dd1ab8d5e313e9bfe65a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 11:43:59 -0500 Subject: [PATCH] fix(tui): preserve prior segment output on Ctrl+C interrupt interruptTurn only flushed the in-flight streaming chunk (bufRef) to the transcript before calling idle(), which wiped segmentMessages and pendingSegmentTools. Every tool call and commentary line the agent had already emitted in the current turn disappeared the moment the user cancelled, even though that output is exactly what they want to keep when they hit Ctrl+C (quote from the blitz feedback: "everything was fine up until the point where you wanted to push to main"). Append each flushed segment message to the transcript first, then render the in-flight partial with the `*[interrupted]*` marker and its pendingSegmentTools. Sys-level "interrupted" note still fires when there is nothing to preserve. --- .../src/ink/events/cmd-shortcuts.test.ts | 4 +- ui-tui/src/__tests__/clipboard.test.ts | 25 +++++++-- ui-tui/src/__tests__/osc52.test.ts | 1 + ui-tui/src/__tests__/platform.test.ts | 1 + ui-tui/src/__tests__/terminalParity.test.ts | 53 ++++++++++++++----- ui-tui/src/__tests__/terminalSetup.test.ts | 16 ++++-- ui-tui/src/__tests__/useComposerState.test.ts | 10 ++-- ui-tui/src/app/slash/commands/core.ts | 34 +++++++----- ui-tui/src/app/slash/commands/session.ts | 2 +- ui-tui/src/app/turnController.ts | 26 ++++++++- ui-tui/src/app/useComposerState.ts | 37 ++++++++++--- ui-tui/src/app/useInputHandlers.ts | 1 - ui-tui/src/app/useMainApp.ts | 2 +- ui-tui/src/components/appChrome.tsx | 9 ++-- ui-tui/src/components/prompts.tsx | 5 +- ui-tui/src/components/textInput.tsx | 11 ++-- ui-tui/src/content/hotkeys.ts | 14 +++-- ui-tui/src/lib/clipboard.ts | 7 ++- ui-tui/src/lib/osc52.ts | 1 + ui-tui/src/lib/platform.ts | 2 +- ui-tui/src/lib/terminalParity.ts | 21 ++++++-- ui-tui/src/lib/terminalSetup.ts | 22 +++++++- 22 files changed, 228 insertions(+), 76 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts index 69e6fdbd..1abd7bbe 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' -import { InputEvent } from './input-event.js' import { parseMultipleKeypresses } from '../parse-keypress.js' +import { InputEvent } from './input-event.js' + function parseOne(sequence: string) { const [keys] = parseMultipleKeypresses({ incomplete: '', mode: 'NORMAL' }, sequence) expect(keys).toHaveLength(1) + return keys[0]! } diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index 3470e4e0..ba14e9be 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -28,7 +28,9 @@ describe('readClipboardText', () => { it('tries powershell.exe first on WSL', async () => { const run = vi.fn().mockResolvedValue({ stdout: 'from wsl\n' }) - await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe('from wsl\n') + await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe( + 'from wsl\n' + ) expect(run).toHaveBeenCalledWith( 'powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], @@ -39,7 +41,9 @@ describe('readClipboardText', () => { it('uses wl-paste on Wayland Linux', async () => { const run = vi.fn().mockResolvedValue({ stdout: 'from wayland\n' }) - await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe('from wayland\n') + await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe( + 'from wayland\n' + ) expect(run).toHaveBeenCalledWith( 'wl-paste', ['--type', 'text'], @@ -53,7 +57,9 @@ describe('readClipboardText', () => { .mockRejectedValueOnce(new Error('wl-paste missing')) .mockResolvedValueOnce({ stdout: 'from xclip\n' }) - await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe('from xclip\n') + await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe( + 'from xclip\n' + ) expect(run).toHaveBeenNthCalledWith( 1, 'wl-paste', @@ -71,7 +77,9 @@ describe('readClipboardText', () => { it('returns null when every clipboard backend fails', async () => { const run = vi.fn().mockRejectedValue(new Error('clipboard failed')) - await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBeNull() + await expect( + readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv) + ).resolves.toBeNull() }) }) @@ -101,6 +109,7 @@ describe('writeClipboardText', () => { it('writes text to pbcopy on macOS', async () => { const stdin = { end: vi.fn() } + const child = { once: vi.fn((event: string, cb: (code?: number) => void) => { if (event === 'close') { @@ -111,10 +120,15 @@ describe('writeClipboardText', () => { }), stdin } + const start = vi.fn().mockReturnValue(child) await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(true) - expect(start).toHaveBeenCalledWith('pbcopy', [], expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })) + expect(start).toHaveBeenCalledWith( + 'pbcopy', + [], + expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) + ) expect(stdin.end).toHaveBeenCalledWith('hello world') }) @@ -129,6 +143,7 @@ describe('writeClipboardText', () => { }), stdin: { end: vi.fn() } } + const start = vi.fn().mockReturnValue(child) await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false) diff --git a/ui-tui/src/__tests__/osc52.test.ts b/ui-tui/src/__tests__/osc52.test.ts index 3d845d5e..a1f5242d 100644 --- a/ui-tui/src/__tests__/osc52.test.ts +++ b/ui-tui/src/__tests__/osc52.test.ts @@ -49,6 +49,7 @@ describe('readOsc52Clipboard', () => { data: `c;${Buffer.from('queried text', 'utf8').toString('base64')}`, type: 'osc' }) + const flush = vi.fn().mockResolvedValue(undefined) await expect(readOsc52Clipboard({ flush, send })).resolves.toBe('queried text') diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index 8465ef0f..1d2f73fe 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -5,6 +5,7 @@ const originalPlatform = process.platform async function importPlatform(platform: NodeJS.Platform) { vi.resetModules() Object.defineProperty(process, 'platform', { value: platform }) + return import('../lib/platform.js') } diff --git a/ui-tui/src/__tests__/terminalParity.test.ts b/ui-tui/src/__tests__/terminalParity.test.ts index 22419938..00543439 100644 --- a/ui-tui/src/__tests__/terminalParity.test.ts +++ b/ui-tui/src/__tests__/terminalParity.test.ts @@ -17,28 +17,55 @@ describe('terminalParityHints', () => { it('suggests IDE setup only for VS Code-family terminals that still need bindings', async () => { const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - const hints = await terminalParityHints( - { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, - { fileOps: { readFile }, homeDir: '/tmp/fake-home' } - ) + const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, { + fileOps: { readFile }, + homeDir: '/tmp/fake-home' + }) + expect(hints.some(h => h.key === 'ide-setup')).toBe(true) }) it('suppresses IDE setup hint when keybindings are already configured', async () => { const readFile = vi.fn().mockResolvedValue( JSON.stringify([ - { key: 'shift+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, - { key: 'ctrl+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, - { key: 'cmd+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, - { key: 'cmd+z', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\u001b[122;9u' } }, - { key: 'shift+cmd+z', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\u001b[122;10u' } } + { + key: 'shift+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\\\r\n' } + }, + { + key: 'ctrl+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\\\r\n' } + }, + { + key: 'cmd+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\\\r\n' } + }, + { + key: 'cmd+z', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\u001b[122;9u' } + }, + { + key: 'shift+cmd+z', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\u001b[122;10u' } + } ]) ) - const hints = await terminalParityHints( - { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, - { fileOps: { readFile }, homeDir: '/tmp/fake-home' } - ) + const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, { + fileOps: { readFile }, + homeDir: '/tmp/fake-home' + }) + expect(hints.some(h => h.key === 'ide-setup')).toBe(false) }) }) diff --git a/ui-tui/src/__tests__/terminalSetup.test.ts b/ui-tui/src/__tests__/terminalSetup.test.ts index 7a5a31cd..de23176f 100644 --- a/ui-tui/src/__tests__/terminalSetup.test.ts +++ b/ui-tui/src/__tests__/terminalSetup.test.ts @@ -21,10 +21,17 @@ describe('terminalSetup helpers', () => { expect(getVSCodeStyleConfigDir('Code', 'darwin', {} as NodeJS.ProcessEnv, '/home/me')).toBe( '/home/me/Library/Application Support/Code/User' ) - expect(getVSCodeStyleConfigDir('Code', 'linux', {} as NodeJS.ProcessEnv, '/home/me')).toBe('/home/me/.config/Code/User') - expect(getVSCodeStyleConfigDir('Code', 'win32', { APPDATA: 'C:/Users/me/AppData/Roaming' } as NodeJS.ProcessEnv, '/home/me')).toBe( - 'C:/Users/me/AppData/Roaming/Code/User' + expect(getVSCodeStyleConfigDir('Code', 'linux', {} as NodeJS.ProcessEnv, '/home/me')).toBe( + '/home/me/.config/Code/User' ) + expect( + getVSCodeStyleConfigDir( + 'Code', + 'win32', + { APPDATA: 'C:/Users/me/AppData/Roaming' } as NodeJS.ProcessEnv, + '/home/me' + ) + ).toBe('C:/Users/me/AppData/Roaming/Code/User') }) it('strips line comments from keybindings JSON', () => { @@ -79,6 +86,7 @@ describe('configureTerminalKeybindings', () => { it('reports conflicts without overwriting existing bindings', async () => { const mkdir = vi.fn().mockResolvedValue(undefined) + const readFile = vi.fn().mockResolvedValue( JSON.stringify([ { @@ -89,6 +97,7 @@ describe('configureTerminalKeybindings', () => { } ]) ) + const writeFile = vi.fn().mockResolvedValue(undefined) const copyFile = vi.fn().mockResolvedValue(undefined) @@ -209,6 +218,7 @@ describe('configureTerminalKeybindings', () => { } ]) ) + await expect( shouldPromptForTerminalSetup({ env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, diff --git a/ui-tui/src/__tests__/useComposerState.test.ts b/ui-tui/src/__tests__/useComposerState.test.ts index 204ed6fe..ff446153 100644 --- a/ui-tui/src/__tests__/useComposerState.test.ts +++ b/ui-tui/src/__tests__/useComposerState.test.ts @@ -1,11 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { looksLikeDroppedPath } from '../app/useComposerState.js' describe('looksLikeDroppedPath', () => { it('recognizes macOS screenshot temp paths and file URIs', () => { - expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot\\ 2026-04-21\\ at\\ 1.04.43 PM.png')).toBe(true) - expect(looksLikeDroppedPath('file:///var/folders/x/T/TemporaryItems/Screenshot%202026-04-21%20at%201.04.43%20PM.png')).toBe(true) + expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot\\ 2026-04-21\\ at\\ 1.04.43 PM.png')).toBe( + true + ) + expect( + looksLikeDroppedPath('file:///var/folders/x/T/TemporaryItems/Screenshot%202026-04-21%20at%201.04.43%20PM.png') + ).toBe(true) }) it('rejects normal multiline or plain text paste', () => { diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index bde9f9c5..3a254b29 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -240,22 +240,28 @@ export const coreCommands: SlashCommand[] = [ return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]') } - const runner = !target || target === 'auto' ? configureDetectedTerminalKeybindings() : configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf') + const runner = + !target || target === 'auto' + ? configureDetectedTerminalKeybindings() + : configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf') - void runner.then(result => { - if (ctx.stale()) { - return - } + void runner + .then(result => { + if (ctx.stale()) { + return + } - ctx.transcript.sys(result.message) - if (result.success && result.requiresRestart) { - ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect') - } - }).catch(error => { - if (!ctx.stale()) { - ctx.transcript.sys(`terminal setup failed: ${String(error)}`) - } - }) + ctx.transcript.sys(result.message) + + if (result.success && result.requiresRestart) { + ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect') + } + }) + .catch(error => { + if (!ctx.stale()) { + ctx.transcript.sys(`terminal setup failed: ${String(error)}`) + } + }) } }, diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 080ed167..5f17667f 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,4 +1,4 @@ -import { introMsg, toTranscriptMessages, attachedImageNotice } from '../../../domain/messages.js' +import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' import type { BackgroundStartResponse, BtwStartResponse, diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 236324ff..43622e7c 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -95,14 +95,36 @@ class TurnController { this.interrupted = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + const segments = this.segmentMessages const partial = this.bufRef.trimStart() + const tools = this.pendingSegmentTools - partial ? appendMessage({ role: 'assistant', text: `${partial}\n\n*[interrupted]*` }) : sys('interrupted') - + // Drain streaming/segment state off the nanostore before writing the + // preserved snapshot to the transcript — otherwise each flushed segment + // appears in both `turn.streamSegments` and the transcript for one frame. this.idle() this.clearReasoning() this.turnTools = [] patchTurnState({ activity: [], outcome: '' }) + + for (const msg of segments) { + appendMessage(msg) + } + + // Always surface an interruption indicator — if there's an in-flight + // `partial` or pending tools, fold them into a single assistant message; + // otherwise emit a sys note so the transcript always records that the + // turn was cancelled, even when only prior `segments` were preserved. + if (partial || tools.length) { + appendMessage({ + role: 'assistant', + text: partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*', + ...(tools.length && { tools }) + }) + } else { + sys('interrupted') + } + patchUiState({ status: 'interrupted' }) this.clearStatusTimer() diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 9c52473f..f229067e 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -3,12 +3,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { useStdin } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useMemo, useState } from 'react' -import { useStdin } from '@hermes/ink' import type { PasteEvent } from '../components/textInput.js' import { LARGE_PASTE } from '../config/limits.js' +import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js' import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' import { useQueue } from '../hooks/useQueue.js' @@ -16,7 +17,6 @@ import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js' import { readOsc52Clipboard } from '../lib/osc52.js' import { isRemoteShellSession } from '../lib/terminalSetup.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' -import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js' import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' import { $isBlocked } from './overlayStore.js' @@ -79,8 +79,8 @@ export function looksLikeDroppedPath(text: string): boolean { trimmed.startsWith("'/") || trimmed.startsWith('"~') || trimmed.startsWith("'~") || - (/^[A-Za-z]:[/\\]/.test(trimmed)) || - (/^["'][A-Za-z]:[/\\]/.test(trimmed)) + /^[A-Za-z]:[/\\]/.test(trimmed) || + /^["'][A-Za-z]:[/\\]/.test(trimmed) ) { return true } @@ -90,13 +90,19 @@ export function looksLikeDroppedPath(text: string): boolean { // unnecessary RPC round-trips. if (trimmed.startsWith('/')) { const rest = trimmed.slice(1) + return rest.includes('/') || rest.includes('.') } return false } -export function useComposerState({ gw, onClipboardPaste, onImageAttached, submitRef }: UseComposerStateOptions): UseComposerStateResult { +export function useComposerState({ + gw, + onClipboardPaste, + onImageAttached, + submitRef +}: UseComposerStateOptions): UseComposerStateResult { const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) const [pasteSnips, setPasteSnips] = useState([]) @@ -119,7 +125,12 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit }, [historyDraftRef, setQueueEdit, setHistoryIdx]) const handleResolvedPaste = useCallback( - async ({ bracketed, cursor, text, value }: Omit): Promise => { + async ({ + bracketed, + cursor, + text, + value + }: Omit): Promise => { const cleanedText = stripTrailingPasteNewlines(text) if (!cleanedText || !/[^\n]/.test(cleanedText)) { @@ -131,6 +142,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit } const sid = getUiState().sid + if (sid && looksLikeDroppedPath(cleanedText)) { try { const attached = await gw.request('image.attach', { @@ -141,6 +153,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit if (attached?.name) { onImageAttached?.(attached) const remainder = attached.remainder?.trim() ?? '' + if (!remainder) { return { cursor, value } } @@ -198,20 +211,29 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit ) const handleTextPaste = useCallback( - ({ bracketed, cursor, hotkey, text, value }: PasteEvent): MaybePromise => { + ({ + bracketed, + cursor, + hotkey, + text, + value + }: PasteEvent): MaybePromise => { if (hotkey) { const preferOsc52 = isRemoteShellSession(process.env) + const readPreferredText = preferOsc52 ? readOsc52Clipboard(querier).then(async osc52Text => { if (isUsableClipboardText(osc52Text)) { return osc52Text } + return readClipboardText() }) : readClipboardText().then(async clipText => { if (isUsableClipboardText(clipText)) { return clipText } + return readOsc52Clipboard(querier) }) @@ -221,6 +243,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit } void onClipboardPaste(false) + return null }) } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index be2e5379..a2b8afb7 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -7,7 +7,6 @@ import type { SudoRespondResponse, VoiceRecordResponse } from '../gatewayTypes.js' - import { isAction, isMac } from '../lib/platform.js' import { getInputSelection } from './inputSelectionStore.js' diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 0c4023a6..a415d343 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' -import { terminalParityHints } from '../lib/terminalParity.js' import { fmtCwdBranch } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' import type { @@ -17,6 +16,7 @@ import type { import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js' diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index da5507e2..28f7b324 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -28,8 +28,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu return ( - {FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}… - {startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''} + {FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}…{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''} ) } @@ -127,7 +126,11 @@ export function StatusRule({ {'─ '} - {busy ? : {status}} + {busy ? ( + + ) : ( + {status} + )} │ {model} {ctxLabel ? │ {ctxLabel} : null} {bar ? ( diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 967634d4..d5071337 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -1,11 +1,11 @@ import { Box, Text, useInput } from '@hermes/ink' import { useState } from 'react' +import { isMac } from '../lib/platform.js' import type { Theme } from '../theme.js' import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js' import { TextInput } from './textInput.js' -import { isMac } from '../lib/platform.js' const OPTS = ['once', 'session', 'always', 'deny'] as const const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const @@ -130,7 +130,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify - Enter send · Esc {choices.length ? 'back' : 'cancel'} · {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} + Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '} + {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 78693aa2..25da66ac 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -277,8 +277,9 @@ function useFwdDelete(active: boolean) { type PasteResult = { cursor: number; value: string } | null -const isPasteResultPromise = (value: PasteResult | Promise | null | undefined): value is Promise => - !!value && typeof (value as PromiseLike).then === 'function' +const isPasteResultPromise = ( + value: PasteResult | Promise | null | undefined +): value is Promise => !!value && typeof (value as PromiseLike).then === 'function' export function TextInput({ columns = 80, @@ -522,9 +523,11 @@ export function TextInput({ } const range = selRange() + const nextValue = range ? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end) : vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current) + const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length commit(nextValue, nextCursor) @@ -778,7 +781,9 @@ interface TextInputProps { focus?: boolean mask?: string onChange: (v: string) => void - onPaste?: (e: PasteEvent) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null + onPaste?: ( + e: PasteEvent + ) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null onSubmit?: (v: string) => void placeholder?: string value: string diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 902b8645..b0938e18 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -4,14 +4,12 @@ const action = isMac ? 'Cmd' : 'Ctrl' const paste = isMac ? 'Cmd' : 'Alt' export const HOTKEYS: [string, string][] = [ - ...( - isMac - ? ([ - ['Cmd+C', 'copy selection'], - ['Ctrl+C', 'interrupt / clear draft / exit'] - ] as [string, string][]) - : ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][]) - ), + ...(isMac + ? ([ + ['Cmd+C', 'copy selection'], + ['Ctrl+C', 'interrupt / clear draft / exit'] + ] as [string, string][]) + : ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][])), [action + '+D', 'exit'], [action + '+G', 'open $EDITOR for prompt'], [action + '+L', 'new session (clear)'], diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 82ce8b34..23e03e5f 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -17,9 +17,11 @@ export function isUsableClipboardText(text: null | string): text is string { } let suspicious = 0 + for (const ch of text) { const code = ch.charCodeAt(0) const isControl = code < 0x20 && ch !== '\n' && ch !== '\r' && ch !== '\t' + if (isControl || ch === '\ufffd') { suspicious += 1 } @@ -28,7 +30,10 @@ export function isUsableClipboardText(text: null | string): text is string { return suspicious <= Math.max(2, Math.floor(text.length * 0.02)) } -function readClipboardCommands(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Array<{ args: readonly string[]; cmd: string }> { +function readClipboardCommands( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): Array<{ args: readonly string[]; cmd: string }> { if (platform === 'darwin') { return [{ cmd: 'pbpaste', args: [] }] } diff --git a/ui-tui/src/lib/osc52.ts b/ui-tui/src/lib/osc52.ts index 5f5a5a8a..aaeecf4c 100644 --- a/ui-tui/src/lib/osc52.ts +++ b/ui-tui/src/lib/osc52.ts @@ -54,6 +54,7 @@ export async function readOsc52Clipboard(querier: null | OscQuerier, timeoutMs = } const timeout = new Promise(resolve => setTimeout(resolve, timeoutMs)) + const query = querier.send({ request: buildOsc52ClipboardQuery(), match: (r: unknown): r is OscResponse => { diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index eb2e2e10..f4a52473 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -13,7 +13,7 @@ export const isMac = process.platform === 'darwin' /** True when the platform action-modifier is pressed (Cmd on macOS, Ctrl elsewhere). */ export const isActionMod = (key: { ctrl: boolean; meta: boolean; super?: boolean }): boolean => - (isMac ? key.meta || key.super === true : key.ctrl) + isMac ? key.meta || key.super === true : key.ctrl /** * Some macOS terminals rewrite Cmd navigation/deletion into readline control keys. diff --git a/ui-tui/src/lib/terminalParity.ts b/ui-tui/src/lib/terminalParity.ts index 72a511a0..9010dedf 100644 --- a/ui-tui/src/lib/terminalParity.ts +++ b/ui-tui/src/lib/terminalParity.ts @@ -1,4 +1,9 @@ -import { detectVSCodeLikeTerminal, isRemoteShellSession, shouldPromptForTerminalSetup, type FileOps } from './terminalSetup.js' +import { + detectVSCodeLikeTerminal, + type FileOps, + isRemoteShellSession, + shouldPromptForTerminalSetup +} from './terminalSetup.js' export type MacTerminalHint = { key: string @@ -31,7 +36,10 @@ export async function terminalParityHints( const ctx = detectMacTerminalContext(env) const hints: MacTerminalHint[] = [] - if (ctx.vscodeLike && (await shouldPromptForTerminalSetup({ env, fileOps: options?.fileOps, homeDir: options?.homeDir }))) { + if ( + ctx.vscodeLike && + (await shouldPromptForTerminalSetup({ env, fileOps: options?.fileOps, homeDir: options?.homeDir })) + ) { hints.push({ key: 'ide-setup', tone: 'info', @@ -43,7 +51,8 @@ export async function terminalParityHints( hints.push({ key: 'apple-terminal', tone: 'warn', - message: 'Apple Terminal detected · use /paste for image-only clipboard fallback, and try Ctrl+A / Ctrl+E / Ctrl+U if Cmd+←/→/⌫ gets rewritten' + message: + 'Apple Terminal detected · use /paste for image-only clipboard fallback, and try Ctrl+A / Ctrl+E / Ctrl+U if Cmd+←/→/⌫ gets rewritten' }) } @@ -51,7 +60,8 @@ export async function terminalParityHints( hints.push({ key: 'tmux', tone: 'warn', - message: 'tmux detected · clipboard copy/paste uses passthrough when available; allow-passthrough improves OSC52 reliability' + message: + 'tmux detected · clipboard copy/paste uses passthrough when available; allow-passthrough improves OSC52 reliability' }) } @@ -59,7 +69,8 @@ export async function terminalParityHints( hints.push({ key: 'remote', tone: 'warn', - message: 'SSH session detected · text clipboard can bridge via OSC52, but image clipboard and local screenshot paths still depend on the machine running Hermes' + message: + 'SSH session detected · text clipboard can bridge via OSC52, but image clipboard and local screenshot paths still depend on the machine running Hermes' }) } diff --git a/ui-tui/src/lib/terminalSetup.ts b/ui-tui/src/lib/terminalSetup.ts index 32cf62c3..3c17734c 100644 --- a/ui-tui/src/lib/terminalSetup.ts +++ b/ui-tui/src/lib/terminalSetup.ts @@ -26,6 +26,7 @@ export type TerminalSetupResult = { const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile } const MULTILINE_SEQUENCE = '\\\r\n' + const TERMINAL_META: Record = { vscode: { appName: 'Code', label: 'VS Code' }, cursor: { appName: 'Cursor', label: 'Cursor' }, @@ -99,18 +100,22 @@ export function stripJsonComments(content: string): string { // String literal — copy as-is, including any comment-like chars inside if (ch === '"') { let j = i + 1 + while (j < len) { if (content[j] === '\\') { j += 2 // skip escaped char } else if (content[j] === '"') { j++ + break } else { j++ } } + result += content.slice(i, j) i = j + continue } @@ -118,6 +123,7 @@ export function stripJsonComments(content: string): string { if (ch === '/' && content[i + 1] === '/') { const eol = content.indexOf('\n', i) i = eol === -1 ? len : eol + continue } @@ -125,6 +131,7 @@ export function stripJsonComments(content: string): string { if (ch === '/' && content[i + 1] === '*') { const end = content.indexOf('*/', i + 2) i = end === -1 ? len : end + 2 + continue } @@ -208,19 +215,23 @@ export async function configureTerminalKeybindings( let keybindings: unknown[] = [] let hasExistingFile = false + try { const content = await ops.readFile(keybindingsFile, 'utf8') hasExistingFile = true const parsed: unknown = JSON.parse(stripJsonComments(content)) + if (!Array.isArray(parsed)) { return { success: false, message: `${meta.label} keybindings.json is not a JSON array: ${keybindingsFile}` } } + keybindings = parsed } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code !== 'ENOENT') { return { success: false, @@ -230,7 +241,9 @@ export async function configureTerminalKeybindings( } const conflicts = TARGET_BINDINGS.filter(target => - keybindings.some(existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target)) + keybindings.some( + existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target) + ) ) if (conflicts.length) { @@ -242,8 +255,10 @@ export async function configureTerminalKeybindings( } let added = 0 + for (const target of TARGET_BINDINGS.slice().reverse()) { const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target)) + if (!exists) { keybindings.unshift(target) added += 1 @@ -320,11 +335,14 @@ export async function shouldPromptForTerminalSetup(options?: { try { const content = await ops.readFile(join(configDir, 'keybindings.json'), 'utf8') const parsed: unknown = JSON.parse(stripJsonComments(content)) + if (!Array.isArray(parsed)) { return true } - return TARGET_BINDINGS.some(target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))) + return TARGET_BINDINGS.some( + target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target)) + ) } catch { return true }