fix(tui): apply path/@ completion on Enter

Completion selection on Enter was gated to slash commands only
(value.startsWith('/')), so @file, ./path, and ~/path completions fell
through and submitted the incomplete input instead of inserting the
highlighted row.

Guard on completions.length && compReplace > 0 — useCompletion already
scopes population to slash and path tokens, and the next !== value check
keeps plain-text submits working when the completion is already applied.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 10:42:31 -05:00
parent ce98e1ef11
commit 4b0686f63d
22 changed files with 206 additions and 76 deletions

View file

@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { InputEvent } from './input-event.js'
import { parseMultipleKeypresses } from '../parse-keypress.js' import { parseMultipleKeypresses } from '../parse-keypress.js'
import { InputEvent } from './input-event.js'
function parseOne(sequence: string) { function parseOne(sequence: string) {
const [keys] = parseMultipleKeypresses({ incomplete: '', mode: 'NORMAL' }, sequence) const [keys] = parseMultipleKeypresses({ incomplete: '', mode: 'NORMAL' }, sequence)
expect(keys).toHaveLength(1) expect(keys).toHaveLength(1)
return keys[0]! return keys[0]!
} }

View file

@ -28,7 +28,9 @@ describe('readClipboardText', () => {
it('tries powershell.exe first on WSL', async () => { it('tries powershell.exe first on WSL', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'from wsl\n' }) 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( expect(run).toHaveBeenCalledWith(
'powershell.exe', 'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
@ -39,7 +41,9 @@ describe('readClipboardText', () => {
it('uses wl-paste on Wayland Linux', async () => { it('uses wl-paste on Wayland Linux', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'from wayland\n' }) 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( expect(run).toHaveBeenCalledWith(
'wl-paste', 'wl-paste',
['--type', 'text'], ['--type', 'text'],
@ -53,7 +57,9 @@ describe('readClipboardText', () => {
.mockRejectedValueOnce(new Error('wl-paste missing')) .mockRejectedValueOnce(new Error('wl-paste missing'))
.mockResolvedValueOnce({ stdout: 'from xclip\n' }) .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( expect(run).toHaveBeenNthCalledWith(
1, 1,
'wl-paste', 'wl-paste',
@ -71,7 +77,9 @@ describe('readClipboardText', () => {
it('returns null when every clipboard backend fails', async () => { it('returns null when every clipboard backend fails', async () => {
const run = vi.fn().mockRejectedValue(new Error('clipboard failed')) 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 () => { it('writes text to pbcopy on macOS', async () => {
const stdin = { end: vi.fn() } const stdin = { end: vi.fn() }
const child = { const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => { once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') { if (event === 'close') {
@ -111,10 +120,15 @@ describe('writeClipboardText', () => {
}), }),
stdin stdin
} }
const start = vi.fn().mockReturnValue(child) const start = vi.fn().mockReturnValue(child)
await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(true) 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') expect(stdin.end).toHaveBeenCalledWith('hello world')
}) })
@ -129,6 +143,7 @@ describe('writeClipboardText', () => {
}), }),
stdin: { end: vi.fn() } stdin: { end: vi.fn() }
} }
const start = vi.fn().mockReturnValue(child) const start = vi.fn().mockReturnValue(child)
await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false) await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false)

View file

@ -49,6 +49,7 @@ describe('readOsc52Clipboard', () => {
data: `c;${Buffer.from('queried text', 'utf8').toString('base64')}`, data: `c;${Buffer.from('queried text', 'utf8').toString('base64')}`,
type: 'osc' type: 'osc'
}) })
const flush = vi.fn().mockResolvedValue(undefined) const flush = vi.fn().mockResolvedValue(undefined)
await expect(readOsc52Clipboard({ flush, send })).resolves.toBe('queried text') await expect(readOsc52Clipboard({ flush, send })).resolves.toBe('queried text')

View file

@ -5,6 +5,7 @@ const originalPlatform = process.platform
async function importPlatform(platform: NodeJS.Platform) { async function importPlatform(platform: NodeJS.Platform) {
vi.resetModules() vi.resetModules()
Object.defineProperty(process, 'platform', { value: platform }) Object.defineProperty(process, 'platform', { value: platform })
return import('../lib/platform.js') return import('../lib/platform.js')
} }

View file

@ -17,28 +17,55 @@ describe('terminalParityHints', () => {
it('suggests IDE setup only for VS Code-family terminals that still need bindings', async () => { 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 readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const hints = await terminalParityHints( const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, {
{ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, fileOps: { readFile },
{ fileOps: { readFile }, homeDir: '/tmp/fake-home' } homeDir: '/tmp/fake-home'
) })
expect(hints.some(h => h.key === 'ide-setup')).toBe(true) expect(hints.some(h => h.key === 'ide-setup')).toBe(true)
}) })
it('suppresses IDE setup hint when keybindings are already configured', async () => { it('suppresses IDE setup hint when keybindings are already configured', async () => {
const readFile = vi.fn().mockResolvedValue( const readFile = vi.fn().mockResolvedValue(
JSON.stringify([ 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: 'shift+enter',
{ key: 'cmd+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, command: 'workbench.action.terminal.sendSequence',
{ key: 'cmd+z', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\u001b[122;9u' } }, when: 'terminalFocus',
{ key: 'shift+cmd+z', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\u001b[122;10u' } } 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( const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, {
{ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, fileOps: { readFile },
{ fileOps: { readFile }, homeDir: '/tmp/fake-home' } homeDir: '/tmp/fake-home'
) })
expect(hints.some(h => h.key === 'ide-setup')).toBe(false) expect(hints.some(h => h.key === 'ide-setup')).toBe(false)
}) })
}) })

View file

@ -21,10 +21,17 @@ describe('terminalSetup helpers', () => {
expect(getVSCodeStyleConfigDir('Code', 'darwin', {} as NodeJS.ProcessEnv, '/home/me')).toBe( expect(getVSCodeStyleConfigDir('Code', 'darwin', {} as NodeJS.ProcessEnv, '/home/me')).toBe(
'/home/me/Library/Application Support/Code/User' '/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', 'linux', {} as NodeJS.ProcessEnv, '/home/me')).toBe(
expect(getVSCodeStyleConfigDir('Code', 'win32', { APPDATA: 'C:/Users/me/AppData/Roaming' } as NodeJS.ProcessEnv, '/home/me')).toBe( '/home/me/.config/Code/User'
'C:/Users/me/AppData/Roaming/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', () => { it('strips line comments from keybindings JSON', () => {
@ -79,6 +86,7 @@ describe('configureTerminalKeybindings', () => {
it('reports conflicts without overwriting existing bindings', async () => { it('reports conflicts without overwriting existing bindings', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined) const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue( const readFile = vi.fn().mockResolvedValue(
JSON.stringify([ JSON.stringify([
{ {
@ -89,6 +97,7 @@ describe('configureTerminalKeybindings', () => {
} }
]) ])
) )
const writeFile = vi.fn().mockResolvedValue(undefined) const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined) const copyFile = vi.fn().mockResolvedValue(undefined)
@ -209,6 +218,7 @@ describe('configureTerminalKeybindings', () => {
} }
]) ])
) )
await expect( await expect(
shouldPromptForTerminalSetup({ shouldPromptForTerminalSetup({
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,

View file

@ -1,11 +1,15 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it } from 'vitest'
import { looksLikeDroppedPath } from '../app/useComposerState.js' import { looksLikeDroppedPath } from '../app/useComposerState.js'
describe('looksLikeDroppedPath', () => { describe('looksLikeDroppedPath', () => {
it('recognizes macOS screenshot temp paths and file URIs', () => { 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('/var/folders/x/T/TemporaryItems/Screenshot\\ 2026-04-21\\ at\\ 1.04.43 PM.png')).toBe(
expect(looksLikeDroppedPath('file:///var/folders/x/T/TemporaryItems/Screenshot%202026-04-21%20at%201.04.43%20PM.png')).toBe(true) 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', () => { it('rejects normal multiline or plain text paste', () => {

View file

@ -240,22 +240,28 @@ export const coreCommands: SlashCommand[] = [
return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]') 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 => { void runner
if (ctx.stale()) { .then(result => {
return if (ctx.stale()) {
} return
}
ctx.transcript.sys(result.message) ctx.transcript.sys(result.message)
if (result.success && result.requiresRestart) {
ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect') 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)}`) .catch(error => {
} if (!ctx.stale()) {
}) ctx.transcript.sys(`terminal setup failed: ${String(error)}`)
}
})
} }
}, },

View file

@ -1,4 +1,4 @@
import { introMsg, toTranscriptMessages, attachedImageNotice } from '../../../domain/messages.js' import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import type { import type {
BackgroundStartResponse, BackgroundStartResponse,
BtwStartResponse, BtwStartResponse,

View file

@ -3,12 +3,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { useStdin } from '@hermes/ink'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useStdin } from '@hermes/ink'
import type { PasteEvent } from '../components/textInput.js' import type { PasteEvent } from '../components/textInput.js'
import { LARGE_PASTE } from '../config/limits.js' import { LARGE_PASTE } from '../config/limits.js'
import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js'
import { useCompletion } from '../hooks/useCompletion.js' import { useCompletion } from '../hooks/useCompletion.js'
import { useInputHistory } from '../hooks/useInputHistory.js' import { useInputHistory } from '../hooks/useInputHistory.js'
import { useQueue } from '../hooks/useQueue.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 { readOsc52Clipboard } from '../lib/osc52.js'
import { isRemoteShellSession } from '../lib/terminalSetup.js' import { isRemoteShellSession } from '../lib/terminalSetup.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.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 type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
import { $isBlocked } from './overlayStore.js' import { $isBlocked } from './overlayStore.js'
@ -79,8 +79,8 @@ export function looksLikeDroppedPath(text: string): boolean {
trimmed.startsWith("'/") || trimmed.startsWith("'/") ||
trimmed.startsWith('"~') || 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 return true
} }
@ -90,13 +90,19 @@ export function looksLikeDroppedPath(text: string): boolean {
// unnecessary RPC round-trips. // unnecessary RPC round-trips.
if (trimmed.startsWith('/')) { if (trimmed.startsWith('/')) {
const rest = trimmed.slice(1) const rest = trimmed.slice(1)
return rest.includes('/') || rest.includes('.') return rest.includes('/') || rest.includes('.')
} }
return false 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 [input, setInput] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([]) const [inputBuf, setInputBuf] = useState<string[]>([])
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([]) const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
@ -119,7 +125,12 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
}, [historyDraftRef, setQueueEdit, setHistoryIdx]) }, [historyDraftRef, setQueueEdit, setHistoryIdx])
const handleResolvedPaste = useCallback( const handleResolvedPaste = useCallback(
async ({ bracketed, cursor, text, value }: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => { async ({
bracketed,
cursor,
text,
value
}: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => {
const cleanedText = stripTrailingPasteNewlines(text) const cleanedText = stripTrailingPasteNewlines(text)
if (!cleanedText || !/[^\n]/.test(cleanedText)) { if (!cleanedText || !/[^\n]/.test(cleanedText)) {
@ -131,6 +142,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
} }
const sid = getUiState().sid const sid = getUiState().sid
if (sid && looksLikeDroppedPath(cleanedText)) { if (sid && looksLikeDroppedPath(cleanedText)) {
try { try {
const attached = await gw.request<ImageAttachResponse>('image.attach', { const attached = await gw.request<ImageAttachResponse>('image.attach', {
@ -141,6 +153,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
if (attached?.name) { if (attached?.name) {
onImageAttached?.(attached) onImageAttached?.(attached)
const remainder = attached.remainder?.trim() ?? '' const remainder = attached.remainder?.trim() ?? ''
if (!remainder) { if (!remainder) {
return { cursor, value } return { cursor, value }
} }
@ -198,20 +211,29 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
) )
const handleTextPaste = useCallback( const handleTextPaste = useCallback(
({ bracketed, cursor, hotkey, text, value }: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => { ({
bracketed,
cursor,
hotkey,
text,
value
}: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => {
if (hotkey) { if (hotkey) {
const preferOsc52 = isRemoteShellSession(process.env) const preferOsc52 = isRemoteShellSession(process.env)
const readPreferredText = preferOsc52 const readPreferredText = preferOsc52
? readOsc52Clipboard(querier).then(async osc52Text => { ? readOsc52Clipboard(querier).then(async osc52Text => {
if (isUsableClipboardText(osc52Text)) { if (isUsableClipboardText(osc52Text)) {
return osc52Text return osc52Text
} }
return readClipboardText() return readClipboardText()
}) })
: readClipboardText().then(async clipText => { : readClipboardText().then(async clipText => {
if (isUsableClipboardText(clipText)) { if (isUsableClipboardText(clipText)) {
return clipText return clipText
} }
return readOsc52Clipboard(querier) return readOsc52Clipboard(querier)
}) })
@ -221,6 +243,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
} }
void onClipboardPaste(false) void onClipboardPaste(false)
return null return null
}) })
} }

View file

@ -7,7 +7,6 @@ import type {
SudoRespondResponse, SudoRespondResponse,
VoiceRecordResponse VoiceRecordResponse
} from '../gatewayTypes.js' } from '../gatewayTypes.js'
import { isAction, isMac } from '../lib/platform.js' import { isAction, isMac } from '../lib/platform.js'
import { getInputSelection } from './inputSelectionStore.js' import { getInputSelection } from './inputSelectionStore.js'

View file

@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { STARTUP_RESUME_ID } from '../config/env.js' import { STARTUP_RESUME_ID } from '../config/env.js'
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
import { terminalParityHints } from '../lib/terminalParity.js'
import { fmtCwdBranch } from '../domain/paths.js' import { fmtCwdBranch } from '../domain/paths.js'
import { type GatewayClient } from '../gatewayClient.js' import { type GatewayClient } from '../gatewayClient.js'
import type { import type {
@ -17,6 +16,7 @@ import type {
import { useGitBranch } from '../hooks/useGitBranch.js' import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import type { Msg, PanelSection, SlashCatalog } from '../types.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js'

View file

@ -234,11 +234,11 @@ export function useSubmission(opts: UseSubmissionOptions) {
const submit = useCallback( const submit = useCallback(
(value: string) => { (value: string) => {
if (value.startsWith('/') && composerState.completions.length) { if (composerState.completions.length) {
const row = composerState.completions[composerState.compIdx] const row = composerState.completions[composerState.compIdx]
if (row?.text) { if (row?.text) {
const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text const text = value.startsWith('/') && row.text.startsWith('/') ? row.text.slice(1) : row.text
const next = value.slice(0, composerState.compReplace) + text const next = value.slice(0, composerState.compReplace) + text
if (next !== value) { if (next !== value) {

View file

@ -28,8 +28,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu
return ( return (
<Text color={color}> <Text color={color}>
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]} {FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
</Text> </Text>
) )
} }
@ -127,7 +126,11 @@ export function StatusRule({
<Box flexShrink={1} width={leftWidth}> <Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze} wrap="truncate-end"> <Text color={t.color.bronze} wrap="truncate-end">
{'─ '} {'─ '}
{busy ? <FaceTicker color={statusColor} startedAt={turnStartedAt} /> : <Text color={statusColor}>{status}</Text>} {busy ? (
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
) : (
<Text color={statusColor}>{status}</Text>
)}
<Text color={t.color.dim}> {model}</Text> <Text color={t.color.dim}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null} {ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? ( {bar ? (

View file

@ -1,11 +1,11 @@
import { Box, Text, useInput } from '@hermes/ink' import { Box, Text, useInput } from '@hermes/ink'
import { useState } from 'react' import { useState } from 'react'
import { isMac } from '../lib/platform.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js' import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
import { TextInput } from './textInput.js' import { TextInput } from './textInput.js'
import { isMac } from '../lib/platform.js'
const OPTS = ['once', 'session', 'always', 'deny'] as const const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } 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
</Box> </Box>
<Text color={t.color.dim}> <Text color={t.color.dim}>
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'}
</Text> </Text>
</Box> </Box>
) )

View file

@ -277,8 +277,9 @@ function useFwdDelete(active: boolean) {
type PasteResult = { cursor: number; value: string } | null type PasteResult = { cursor: number; value: string } | null
const isPasteResultPromise = (value: PasteResult | Promise<PasteResult> | null | undefined): value is Promise<PasteResult> => const isPasteResultPromise = (
!!value && typeof (value as PromiseLike<PasteResult>).then === 'function' value: PasteResult | Promise<PasteResult> | null | undefined
): value is Promise<PasteResult> => !!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
export function TextInput({ export function TextInput({
columns = 80, columns = 80,
@ -522,9 +523,11 @@ export function TextInput({
} }
const range = selRange() const range = selRange()
const nextValue = range const nextValue = range
? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end) ? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end)
: vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current) : vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current)
const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length
commit(nextValue, nextCursor) commit(nextValue, nextCursor)
@ -778,7 +781,9 @@ interface TextInputProps {
focus?: boolean focus?: boolean
mask?: string mask?: string
onChange: (v: string) => void 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 onSubmit?: (v: string) => void
placeholder?: string placeholder?: string
value: string value: string

View file

@ -4,14 +4,12 @@ const action = isMac ? 'Cmd' : 'Ctrl'
const paste = isMac ? 'Cmd' : 'Alt' const paste = isMac ? 'Cmd' : 'Alt'
export const HOTKEYS: [string, string][] = [ export const HOTKEYS: [string, string][] = [
...( ...(isMac
isMac ? ([
? ([ ['Cmd+C', 'copy selection'],
['Cmd+C', 'copy selection'], ['Ctrl+C', 'interrupt / clear draft / exit']
['Ctrl+C', 'interrupt / clear draft / exit'] ] as [string, string][])
] as [string, string][]) : ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][])),
: ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][])
),
[action + '+D', 'exit'], [action + '+D', 'exit'],
[action + '+G', 'open $EDITOR for prompt'], [action + '+G', 'open $EDITOR for prompt'],
[action + '+L', 'new session (clear)'], [action + '+L', 'new session (clear)'],

View file

@ -17,9 +17,11 @@ export function isUsableClipboardText(text: null | string): text is string {
} }
let suspicious = 0 let suspicious = 0
for (const ch of text) { for (const ch of text) {
const code = ch.charCodeAt(0) const code = ch.charCodeAt(0)
const isControl = code < 0x20 && ch !== '\n' && ch !== '\r' && ch !== '\t' const isControl = code < 0x20 && ch !== '\n' && ch !== '\r' && ch !== '\t'
if (isControl || ch === '\ufffd') { if (isControl || ch === '\ufffd') {
suspicious += 1 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)) 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') { if (platform === 'darwin') {
return [{ cmd: 'pbpaste', args: [] }] return [{ cmd: 'pbpaste', args: [] }]
} }

View file

@ -54,6 +54,7 @@ export async function readOsc52Clipboard(querier: null | OscQuerier, timeoutMs =
} }
const timeout = new Promise<undefined>(resolve => setTimeout(resolve, timeoutMs)) const timeout = new Promise<undefined>(resolve => setTimeout(resolve, timeoutMs))
const query = querier.send<OscResponse>({ const query = querier.send<OscResponse>({
request: buildOsc52ClipboardQuery(), request: buildOsc52ClipboardQuery(),
match: (r: unknown): r is OscResponse => { match: (r: unknown): r is OscResponse => {

View file

@ -13,7 +13,7 @@ export const isMac = process.platform === 'darwin'
/** True when the platform action-modifier is pressed (Cmd on macOS, Ctrl elsewhere). */ /** True when the platform action-modifier is pressed (Cmd on macOS, Ctrl elsewhere). */
export const isActionMod = (key: { ctrl: boolean; meta: boolean; super?: boolean }): boolean => 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. * Some macOS terminals rewrite Cmd navigation/deletion into readline control keys.

View file

@ -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 = { export type MacTerminalHint = {
key: string key: string
@ -31,7 +36,10 @@ export async function terminalParityHints(
const ctx = detectMacTerminalContext(env) const ctx = detectMacTerminalContext(env)
const hints: MacTerminalHint[] = [] 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({ hints.push({
key: 'ide-setup', key: 'ide-setup',
tone: 'info', tone: 'info',
@ -43,7 +51,8 @@ export async function terminalParityHints(
hints.push({ hints.push({
key: 'apple-terminal', key: 'apple-terminal',
tone: 'warn', 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({ hints.push({
key: 'tmux', key: 'tmux',
tone: 'warn', 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({ hints.push({
key: 'remote', key: 'remote',
tone: 'warn', 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'
}) })
} }

View file

@ -26,6 +26,7 @@ export type TerminalSetupResult = {
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile } const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
const MULTILINE_SEQUENCE = '\\\r\n' const MULTILINE_SEQUENCE = '\\\r\n'
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = { const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = {
vscode: { appName: 'Code', label: 'VS Code' }, vscode: { appName: 'Code', label: 'VS Code' },
cursor: { appName: 'Cursor', label: 'Cursor' }, 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 // String literal — copy as-is, including any comment-like chars inside
if (ch === '"') { if (ch === '"') {
let j = i + 1 let j = i + 1
while (j < len) { while (j < len) {
if (content[j] === '\\') { if (content[j] === '\\') {
j += 2 // skip escaped char j += 2 // skip escaped char
} else if (content[j] === '"') { } else if (content[j] === '"') {
j++ j++
break break
} else { } else {
j++ j++
} }
} }
result += content.slice(i, j) result += content.slice(i, j)
i = j i = j
continue continue
} }
@ -118,6 +123,7 @@ export function stripJsonComments(content: string): string {
if (ch === '/' && content[i + 1] === '/') { if (ch === '/' && content[i + 1] === '/') {
const eol = content.indexOf('\n', i) const eol = content.indexOf('\n', i)
i = eol === -1 ? len : eol i = eol === -1 ? len : eol
continue continue
} }
@ -125,6 +131,7 @@ export function stripJsonComments(content: string): string {
if (ch === '/' && content[i + 1] === '*') { if (ch === '/' && content[i + 1] === '*') {
const end = content.indexOf('*/', i + 2) const end = content.indexOf('*/', i + 2)
i = end === -1 ? len : end + 2 i = end === -1 ? len : end + 2
continue continue
} }
@ -208,19 +215,23 @@ export async function configureTerminalKeybindings(
let keybindings: unknown[] = [] let keybindings: unknown[] = []
let hasExistingFile = false let hasExistingFile = false
try { try {
const content = await ops.readFile(keybindingsFile, 'utf8') const content = await ops.readFile(keybindingsFile, 'utf8')
hasExistingFile = true hasExistingFile = true
const parsed: unknown = JSON.parse(stripJsonComments(content)) const parsed: unknown = JSON.parse(stripJsonComments(content))
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
return { return {
success: false, success: false,
message: `${meta.label} keybindings.json is not a JSON array: ${keybindingsFile}` message: `${meta.label} keybindings.json is not a JSON array: ${keybindingsFile}`
} }
} }
keybindings = parsed keybindings = parsed
} catch (error) { } catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT') { if (code !== 'ENOENT') {
return { return {
success: false, success: false,
@ -230,7 +241,9 @@ export async function configureTerminalKeybindings(
} }
const conflicts = TARGET_BINDINGS.filter(target => 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) { if (conflicts.length) {
@ -242,8 +255,10 @@ export async function configureTerminalKeybindings(
} }
let added = 0 let added = 0
for (const target of TARGET_BINDINGS.slice().reverse()) { for (const target of TARGET_BINDINGS.slice().reverse()) {
const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target)) const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
if (!exists) { if (!exists) {
keybindings.unshift(target) keybindings.unshift(target)
added += 1 added += 1
@ -320,11 +335,14 @@ export async function shouldPromptForTerminalSetup(options?: {
try { try {
const content = await ops.readFile(join(configDir, 'keybindings.json'), 'utf8') const content = await ops.readFile(join(configDir, 'keybindings.json'), 'utf8')
const parsed: unknown = JSON.parse(stripJsonComments(content)) const parsed: unknown = JSON.parse(stripJsonComments(content))
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
return true 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 { } catch {
return true return true
} }