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 69e6fdbd0e2..1abd7bbe006 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 3470e4e08b8..ba14e9bebc2 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 3d845d5ef70..a1f5242ddbd 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 8465ef0f112..1d2f73fe469 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 224199389ba..0054343968b 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 7a5a31cd38f..de23176f26b 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 204ed6fe6fe..ff446153a63 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 bde9f9c59c5..3a254b29391 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 080ed167f9f..5f17667f038 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 236324ffb98..43622e7c7aa 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 9c52473f9d6..f229067edc4 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 be2e5379e92..a2b8afb7c1d 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 0c4023a6228..a415d343793 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 da5507e28c2..28f7b324e2f 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 967634d41f2..d5071337925 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 78693aa2d1f..25da66accbe 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 902b864599f..b0938e18eb9 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 82ce8b34c48..23e03e5feb8 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 5f5a5a8aed7..aaeecf4c932 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 eb2e2e10cda..f4a52473301 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 72a511a0580..9010dedfc77 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 32cf62c39f1..3c17734c63f 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 }