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 }