mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(tui): delegate unknown /tools subcommand to slash.exec
/tools' local handler silently returned for anything other than enable or disable, so /tools list and friends looked broken even though the Python CLI already implements them (hermes_cli/main.py registers tools_sub for list/enable/disable). Keep the client-owned enable/disable path (which has to run session.setSessionStartedAt + resetVisibleHistory locally) and route every other sub through slash.exec, matching createSlashHandler's page/sys split for long vs short output.
This commit is contained in:
parent
ce98e1ef11
commit
83c1d4ec27
22 changed files with 221 additions and 76 deletions
|
|
@ -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]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -240,18 +240,24 @@ 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
|
||||||
|
.then(result => {
|
||||||
if (ctx.stale()) {
|
if (ctx.stale()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.transcript.sys(result.message)
|
ctx.transcript.sys(result.message)
|
||||||
|
|
||||||
if (result.success && result.requiresRestart) {
|
if (result.success && result.requiresRestart) {
|
||||||
ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect')
|
ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect')
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch(error => {
|
||||||
if (!ctx.stale()) {
|
if (!ctx.stale()) {
|
||||||
ctx.transcript.sys(`terminal setup failed: ${String(error)}`)
|
ctx.transcript.sys(`terminal setup failed: ${String(error)}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ToolsConfigureResponse } from '../../../gatewayTypes.js'
|
import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js'
|
||||||
import type { PanelSection } from '../../../types.js'
|
import type { PanelSection } from '../../../types.js'
|
||||||
import { patchOverlayState } from '../../overlayStore.js'
|
import { patchOverlayState } from '../../overlayStore.js'
|
||||||
import type { SlashCommand } from '../types.js'
|
import type { SlashCommand } from '../types.js'
|
||||||
|
|
@ -207,10 +207,25 @@ export const opsCommands: SlashCommand[] = [
|
||||||
{
|
{
|
||||||
help: 'enable or disable tools (client-side history reset on change)',
|
help: 'enable or disable tools (client-side history reset on change)',
|
||||||
name: 'tools',
|
name: 'tools',
|
||||||
run: (arg, ctx) => {
|
run: (arg, ctx, cmd) => {
|
||||||
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
|
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
if (subcommand !== 'disable' && subcommand !== 'enable') {
|
if (subcommand !== 'disable' && subcommand !== 'enable') {
|
||||||
|
ctx.gateway.gw
|
||||||
|
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
|
||||||
|
.then(r => {
|
||||||
|
if (ctx.stale()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = r?.output || '/tools: no output'
|
||||||
|
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
|
||||||
|
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
||||||
|
|
||||||
|
long ? ctx.transcript.page(text, 'Tools') : ctx.transcript.sys(text)
|
||||||
|
})
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)'],
|
||||||
|
|
|
||||||
|
|
@ -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: [] }]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue