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 69e6fdbd0..1abd7bbe0 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 3470e4e08..ba14e9beb 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 3d845d5ef..a1f5242dd 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 8465ef0f1..1d2f73fe4 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 224199389..005434396 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 7a5a31cd3..de23176f2 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 204ed6fe6..ff446153a 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 bde9f9c59..3a254b293 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 080ed167f..5f17667f0 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 236324ffb..43622e7c7 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 9c52473f9..f229067ed 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 be2e5379e..a2b8afb7c 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 0c4023a62..a415d3437 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 da5507e28..28f7b324e 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 967634d41..d50713379 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 78693aa2d..25da66acc 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 902b86459..b0938e18e 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 82ce8b34c..23e03e5fe 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 5f5a5a8ae..aaeecf4c9 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 eb2e2e10c..f4a524733 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 72a511a05..9010dedfc 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 32cf62c39..3c17734c6 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 }