mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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.
This commit is contained in:
parent
ce98e1ef11
commit
dd5ead1007
22 changed files with 228 additions and 76 deletions
|
|
@ -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]!
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { introMsg, toTranscriptMessages, attachedImageNotice } from '../../../domain/messages.js'
|
||||
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
|
||||
import type {
|
||||
BackgroundStartResponse,
|
||||
BtwStartResponse,
|
||||
|
|
|
|||
|
|
@ -95,14 +95,36 @@ class TurnController {
|
|||
this.interrupted = true
|
||||
gw.request<SessionInterruptResponse>('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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string[]>([])
|
||||
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
|
||||
|
|
@ -119,7 +125,12 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
|
|||
}, [historyDraftRef, setQueueEdit, setHistoryIdx])
|
||||
|
||||
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)
|
||||
|
||||
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<ImageAttachResponse>('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<null | { cursor: number; value: string }> => {
|
||||
({
|
||||
bracketed,
|
||||
cursor,
|
||||
hotkey,
|
||||
text,
|
||||
value
|
||||
}: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type {
|
|||
SudoRespondResponse,
|
||||
VoiceRecordResponse
|
||||
} from '../gatewayTypes.js'
|
||||
|
||||
import { isAction, isMac } from '../lib/platform.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu
|
|||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}…
|
||||
{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
|
||||
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}…{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
@ -127,7 +126,11 @@ export function StatusRule({
|
|||
<Box flexShrink={1} width={leftWidth}>
|
||||
<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>
|
||||
{ctxLabel ? <Text color={t.color.dim}> │ {ctxLabel}</Text> : null}
|
||||
{bar ? (
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</Box>
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -277,8 +277,9 @@ function useFwdDelete(active: boolean) {
|
|||
|
||||
type PasteResult = { cursor: number; value: string } | null
|
||||
|
||||
const isPasteResultPromise = (value: PasteResult | Promise<PasteResult> | null | undefined): value is Promise<PasteResult> =>
|
||||
!!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
|
||||
const isPasteResultPromise = (
|
||||
value: PasteResult | Promise<PasteResult> | null | undefined
|
||||
): value is Promise<PasteResult> => !!value && typeof (value as PromiseLike<PasteResult>).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
|
||||
|
|
|
|||
|
|
@ -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)'],
|
||||
|
|
|
|||
|
|
@ -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: [] }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export async function readOsc52Clipboard(querier: null | OscQuerier, timeoutMs =
|
|||
}
|
||||
|
||||
const timeout = new Promise<undefined>(resolve => setTimeout(resolve, timeoutMs))
|
||||
|
||||
const query = querier.send<OscResponse>({
|
||||
request: buildOsc52ClipboardQuery(),
|
||||
match: (r: unknown): r is OscResponse => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SupportedTerminal, { appName: string; label: string }> = {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue