fix(tui): improve macOS paste and shortcut parity

- support Cmd-as-super and readline-style fallback shortcuts on macOS
- add layered clipboard/OSC52 paste handling and immediate image-path attach
- add IDE terminal setup helpers, terminal parity hints, and aligned docs
This commit is contained in:
kshitijk4poor 2026-04-21 14:27:28 +05:30 committed by kshitij
parent 432772dbdf
commit 9556fef5a1
31 changed files with 1303 additions and 100 deletions

32
cli.py
View file

@ -26,6 +26,7 @@ import tempfile
import time
import uuid
import textwrap
from urllib.parse import unquote, urlparse
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime
@ -1271,10 +1272,21 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
if (token.startswith('"') and token.endswith('"')) or (token.startswith("'") and token.endswith("'")):
token = token[1:-1].strip()
token = token.replace('\\ ', ' ')
if not token:
return None
expanded = os.path.expandvars(os.path.expanduser(token))
expanded = token
if token.startswith("file://"):
try:
parsed = urlparse(token)
if parsed.scheme == "file":
expanded = unquote(parsed.path or "")
if parsed.netloc and os.name == "nt":
expanded = f"//{parsed.netloc}{expanded}"
except Exception:
expanded = token
expanded = os.path.expandvars(os.path.expanduser(expanded))
if os.name != "nt":
normalized = expanded.replace("\\", "/")
if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha():
@ -1361,6 +1373,7 @@ def _detect_file_drop(user_input: str) -> "dict | None":
or stripped.startswith("~")
or stripped.startswith("./")
or stripped.startswith("../")
or stripped.startswith("file://")
or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha())
or stripped.startswith('"/')
or stripped.startswith('"~')
@ -1371,8 +1384,25 @@ def _detect_file_drop(user_input: str) -> "dict | None":
if not starts_like_path:
return None
direct_path = _resolve_attachment_path(stripped)
if direct_path is not None:
return {
"path": direct_path,
"is_image": direct_path.suffix.lower() in _IMAGE_EXTENSIONS,
"remainder": "",
}
first_token, remainder = _split_path_input(stripped)
drop_path = _resolve_attachment_path(first_token)
if drop_path is None and " " in stripped and stripped[0] not in {"'", '"'}:
space_positions = [idx for idx, ch in enumerate(stripped) if ch == " "]
for pos in reversed(space_positions):
candidate = stripped[:pos].rstrip()
resolved = _resolve_attachment_path(candidate)
if resolved is not None:
drop_path = resolved
remainder = stripped[pos + 1 :].strip()
break
if drop_path is None:
return None

View file

@ -147,6 +147,37 @@ class TestEscapedSpaces:
assert result["path"] == tmp_image_with_spaces
assert result["remainder"] == "what is this?"
def test_unquoted_spaces_in_path(self, tmp_image_with_spaces):
result = _detect_file_drop(str(tmp_image_with_spaces))
assert result is not None
assert result["path"] == tmp_image_with_spaces
assert result["is_image"] is True
assert result["remainder"] == ""
def test_unquoted_spaces_with_trailing_text(self, tmp_image_with_spaces):
user_input = f"{tmp_image_with_spaces} what is this?"
result = _detect_file_drop(user_input)
assert result is not None
assert result["path"] == tmp_image_with_spaces
assert result["remainder"] == "what is this?"
def test_mixed_escaped_and_literal_spaces_in_path(self, tmp_path):
img = tmp_path / "Screenshot 2026-04-21 at 1.04.43 PM.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n")
mixed = str(img).replace("Screenshot ", "Screenshot\\ ").replace("2026-04-21 ", "2026-04-21\\ ").replace("at ", "at\\ ")
result = _detect_file_drop(mixed)
assert result is not None
assert result["path"] == img
assert result["is_image"] is True
assert result["remainder"] == ""
def test_file_uri_image_path(self, tmp_image_with_spaces):
uri = tmp_image_with_spaces.as_uri()
result = _detect_file_drop(uri)
assert result is not None
assert result["path"] == tmp_image_with_spaces
assert result["is_image"] is True
def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
home = tmp_path / "home"
img = home / "storage" / "shared" / "Pictures" / "cat.png"

View file

@ -147,6 +147,27 @@ class TestEscapedSpaces:
assert result["path"] == tmp_image_with_spaces
assert result["remainder"] == "what is this?"
def test_unquoted_spaces_in_path(self, tmp_image_with_spaces):
result = _detect_file_drop(str(tmp_image_with_spaces))
assert result is not None
assert result["path"] == tmp_image_with_spaces
assert result["is_image"] is True
assert result["remainder"] == ""
def test_unquoted_spaces_with_trailing_text(self, tmp_image_with_spaces):
user_input = f"{tmp_image_with_spaces} what is this?"
result = _detect_file_drop(user_input)
assert result is not None
assert result["path"] == tmp_image_with_spaces
assert result["remainder"] == "what is this?"
def test_file_uri_image_path(self, tmp_image_with_spaces):
uri = tmp_image_with_spaces.as_uri()
result = _detect_file_drop(uri)
assert result is not None
assert result["path"] == tmp_image_with_spaces
assert result["is_image"] is True
# ---------------------------------------------------------------------------
# Tests: edge cases

View file

@ -350,6 +350,11 @@ def test_prompt_submit_expands_context_refs(monkeypatch):
def test_image_attach_appends_local_image(monkeypatch):
fake_cli = types.ModuleType("cli")
fake_cli._IMAGE_EXTENSIONS = {".png"}
fake_cli._detect_file_drop = lambda raw: {
"path": Path("/tmp/cat.png"),
"is_image": True,
"remainder": "",
}
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: Path("/tmp/cat.png")
@ -363,6 +368,31 @@ def test_image_attach_appends_local_image(monkeypatch):
assert len(server._sessions["sid"]["attached_images"]) == 1
def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
screenshot = Path("/tmp/Screenshot 2026-04-21 at 1.04.43 PM.png")
fake_cli = types.ModuleType("cli")
fake_cli._IMAGE_EXTENSIONS = {".png"}
fake_cli._detect_file_drop = lambda raw: {
"path": screenshot,
"is_image": True,
"remainder": "",
}
fake_cli._split_path_input = lambda raw: ("/tmp/Screenshot", "2026-04-21 at 1.04.43 PM.png")
fake_cli._resolve_attachment_path = lambda raw: None
server._sessions["sid"] = _session()
monkeypatch.setitem(sys.modules, "cli", fake_cli)
resp = server.handle_request(
{"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": str(screenshot)}}
)
assert resp["result"]["attached"] is True
assert resp["result"]["path"] == str(screenshot)
assert resp["result"]["remainder"] == ""
assert len(server._sessions["sid"]["attached_images"]) == 1
def test_commands_catalog_surfaces_quick_commands(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {
"build": {"type": "exec", "command": "npm run build"},

View file

@ -1644,12 +1644,17 @@ def _(rid, params: dict) -> dict:
if not raw:
return _err(rid, 4015, "path required")
try:
from cli import _IMAGE_EXTENSIONS, _resolve_attachment_path, _split_path_input
from cli import _IMAGE_EXTENSIONS, _detect_file_drop, _resolve_attachment_path, _split_path_input
path_token, remainder = _split_path_input(raw)
image_path = _resolve_attachment_path(path_token)
if image_path is None:
return _err(rid, 4016, f"image not found: {path_token}")
dropped = _detect_file_drop(raw)
if dropped:
image_path = dropped["path"]
remainder = dropped["remainder"]
else:
path_token, remainder = _split_path_input(raw)
image_path = _resolve_attachment_path(path_token)
if image_path is None:
return _err(rid, 4016, f"image not found: {path_token}")
if image_path.suffix.lower() not in _IMAGE_EXTENSIONS:
return _err(rid, 4016, f"unsupported image: {image_path.name}")
session.setdefault("attached_images", []).append(str(image_path))

View file

@ -112,7 +112,7 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an
| `Ctrl+D` | Exit |
| `Ctrl+G` | Open `$EDITOR` with the current draft |
| `Ctrl+L` | New session (same as `/clear`) |
| `Ctrl+V` / `Alt+V` | Paste clipboard image (same as `/paste`) |
| `Ctrl+V` / `Alt+V` | Paste text first, then fall back to image/path attachment when applicable |
| `Tab` | Apply the active completion |
| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history |
| `Left/Right` | Move the cursor |
@ -217,8 +217,8 @@ The local slash handler covers the built-ins that need direct client behavior:
Notes:
- `/copy` sends the selected assistant response through OSC 52.
- `/paste` with no args asks the gateway for clipboard image attachment state.
- `/paste` does not manage text paste entries; text paste is inline-only.
- `/paste` with no args asks the gateway to attach a clipboard image.
- Text paste remains inline-only; `Cmd+V` / `Ctrl+V` handle layered text/OSC52/image fallback before `/paste` is needed.
- `/details [hidden|collapsed|expanded|cycle]` controls thinking/tool-detail visibility.
- `/statusbar` toggles the status rule on/off.

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { InputEvent } from './input-event.js'
import { parseMultipleKeypresses } from '../parse-keypress.js'
function parseOne(sequence: string) {
const [keys] = parseMultipleKeypresses({ incomplete: '', mode: 'NORMAL' }, sequence)
expect(keys).toHaveLength(1)
return keys[0]!
}
describe('InputEvent macOS command modifiers', () => {
it('preserves Cmd as super for kitty keyboard CSI-u sequences', () => {
const parsed = parseOne('\u001b[99;9u')
const event = new InputEvent(parsed)
expect(parsed.name).toBe('c')
expect(event.key.meta).toBe(false)
expect(event.key.super).toBe(true)
})
it('preserves Cmd on word-delete and word-navigation sequences', () => {
const backspace = new InputEvent(parseOne('\u001b[127;9u'))
const left = new InputEvent(parseOne('\u001b[1;9D'))
const right = new InputEvent(parseOne('\u001b[1;9C'))
expect(backspace.key.backspace).toBe(true)
expect(backspace.key.super).toBe(true)
expect(left.key.leftArrow).toBe(true)
expect(left.key.super).toBe(true)
expect(right.key.rightArrow).toBe(true)
expect(right.key.super).toBe(true)
})
})

View file

@ -1,26 +1,93 @@
import { describe, expect, it, vi } from 'vitest'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
import { isUsableClipboardText, readClipboardText, writeClipboardText } from '../lib/clipboard.js'
describe('readClipboardText', () => {
it('does nothing off macOS', async () => {
const run = vi.fn()
await expect(readClipboardText('linux', run)).resolves.toBeNull()
expect(run).not.toHaveBeenCalled()
})
it('reads text from pbpaste on macOS', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'hello world\n' })
await expect(readClipboardText('darwin', run)).resolves.toBe('hello world\n')
expect(run).toHaveBeenCalledWith('pbpaste', [], expect.objectContaining({ encoding: 'utf8', windowsHide: true }))
expect(run).toHaveBeenCalledWith(
'pbpaste',
[],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
it('returns null when pbpaste fails', async () => {
const run = vi.fn().mockRejectedValue(new Error('pbpaste failed'))
it('reads text from PowerShell on Windows', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'from windows\r\n' })
await expect(readClipboardText('darwin', run)).resolves.toBeNull()
await expect(readClipboardText('win32', run)).resolves.toBe('from windows\r\n')
expect(run).toHaveBeenCalledWith(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
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')
expect(run).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
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')
expect(run).toHaveBeenCalledWith(
'wl-paste',
['--type', 'text'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
it('falls back to xclip on Linux when wl-paste fails', async () => {
const run = vi
.fn()
.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')
expect(run).toHaveBeenNthCalledWith(
1,
'wl-paste',
['--type', 'text'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
expect(run).toHaveBeenNthCalledWith(
2,
'xclip',
['-selection', 'clipboard', '-out'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
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()
})
})
describe('isUsableClipboardText', () => {
it('accepts normal text', () => {
expect(isUsableClipboardText('hello world\n')).toBe(true)
})
it('rejects empty or whitespace-only content', () => {
expect(isUsableClipboardText('')).toBe(false)
expect(isUsableClipboardText(' \n\t')).toBe(false)
})
it('rejects binary-looking clipboard payloads', () => {
expect(isUsableClipboardText('PNG\u0000\u0001\u0002\u0003IHDR')).toBe(false)
expect(isUsableClipboardText('TIFF\ufffd\ufffd\ufffdmetadata')).toBe(false)
})
})

View file

@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildOsc52ClipboardQuery,
OSC52_CLIPBOARD_QUERY,
parseOsc52ClipboardData,
readOsc52Clipboard
} from '../lib/osc52.js'
const envBackup = { ...process.env }
afterEach(() => {
process.env = { ...envBackup }
})
describe('buildOsc52ClipboardQuery', () => {
it('returns the raw OSC52 query outside multiplexers', () => {
delete process.env.TMUX
delete process.env.STY
expect(buildOsc52ClipboardQuery()).toBe(OSC52_CLIPBOARD_QUERY)
})
it('wraps the query for tmux passthrough', () => {
process.env.TMUX = '/tmp/tmux-123/default,1,0'
expect(buildOsc52ClipboardQuery()).toContain('\x1bPtmux;')
expect(buildOsc52ClipboardQuery()).toContain(']52;c;?')
})
})
describe('parseOsc52ClipboardData', () => {
it('decodes clipboard payloads', () => {
const encoded = Buffer.from('hello from osc52', 'utf8').toString('base64')
expect(parseOsc52ClipboardData(`c;${encoded}`)).toBe('hello from osc52')
})
it('returns null for empty or query payloads', () => {
expect(parseOsc52ClipboardData('c;?')).toBeNull()
expect(parseOsc52ClipboardData('c;')).toBeNull()
})
})
describe('readOsc52Clipboard', () => {
it('returns decoded text from a terminal OSC52 response', async () => {
const send = vi.fn().mockResolvedValue({
code: 52,
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')
expect(send).toHaveBeenCalled()
expect(flush).toHaveBeenCalled()
})
it('returns null when the querier is missing or unsupported', async () => {
await expect(readOsc52Clipboard(null)).resolves.toBeNull()
const send = vi.fn().mockResolvedValue(undefined)
const flush = vi.fn().mockResolvedValue(undefined)
await expect(readOsc52Clipboard({ flush, send })).resolves.toBeNull()
})
})

View file

@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalPlatform = process.platform
async function importPlatform(platform: NodeJS.Platform) {
vi.resetModules()
Object.defineProperty(process, 'platform', { value: platform })
return import('../lib/platform.js')
}
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.resetModules()
})
describe('platform action modifier', () => {
it('treats kitty Cmd sequences as the macOS action modifier', async () => {
const { isActionMod } = await importPlatform('darwin')
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(true)
expect(isActionMod({ ctrl: false, meta: true, super: false })).toBe(true)
expect(isActionMod({ ctrl: true, meta: false, super: false })).toBe(false)
})
it('still uses Ctrl as the action modifier on non-macOS', async () => {
const { isActionMod } = await importPlatform('linux')
expect(isActionMod({ ctrl: true, meta: false, super: false })).toBe(true)
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(false)
})
})

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { terminalParityHints } from '../lib/terminalParity.js'
describe('terminalParityHints', () => {
it('warns for Apple Terminal and SSH/tmux sessions', async () => {
const hints = await terminalParityHints({
TERM_PROGRAM: 'Apple_Terminal',
TERM_SESSION_ID: 'w0t0p0:123',
SSH_CONNECTION: '1',
TMUX: '/tmp/tmux-1/default,1,0'
} as NodeJS.ProcessEnv)
expect(hints.map(h => h.key)).toEqual(expect.arrayContaining(['apple-terminal', 'remote', 'tmux']))
})
it('suggests IDE setup only for VS Code-family terminals that still need bindings', async () => {
const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)
expect(hints.some(h => h.key === 'ide-setup')).toBe(true)
})
})

View file

@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest'
import {
configureDetectedTerminalKeybindings,
configureTerminalKeybindings,
detectVSCodeLikeTerminal,
getVSCodeStyleConfigDir,
shouldPromptForTerminalSetup,
stripJsonComments
} from '../lib/terminalSetup.js'
describe('terminalSetup helpers', () => {
it('detects VS Code family terminals from environment', () => {
expect(detectVSCodeLikeTerminal({ CURSOR_TRACE_ID: 'x' } as NodeJS.ProcessEnv)).toBe('cursor')
expect(detectVSCodeLikeTerminal({ VSCODE_GIT_ASKPASS_MAIN: '/tmp/windsurf' } as NodeJS.ProcessEnv)).toBe('windsurf')
expect(detectVSCodeLikeTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe('vscode')
expect(detectVSCodeLikeTerminal({} as NodeJS.ProcessEnv)).toBeNull()
})
it('computes VS Code style config dirs cross-platform', () => {
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'
)
})
it('strips line comments from keybindings JSON', () => {
expect(stripJsonComments('// comment\n[{"key":"shift+enter"}]')).toBe('\n[{"key":"shift+enter"}]')
})
})
describe('configureTerminalKeybindings', () => {
it('writes missing bindings into a VS Code style keybindings file', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(result.requiresRestart).toBe(true)
expect(writeFile).toHaveBeenCalledTimes(1)
const written = writeFile.mock.calls[0]?.[1] as string
expect(written).toContain('shift+enter')
expect(written).toContain('cmd+enter')
expect(written).toContain('cmd+z')
})
it('reports conflicts without overwriting existing bindings', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+z',
command: 'something.else',
when: 'terminalFocus',
args: { text: 'noop' }
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('cursor', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('cmd+z')
expect(writeFile).not.toHaveBeenCalled()
})
it('auto-detects the current IDE terminal', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureDetectedTerminalKeybindings({
env: { CURSOR_TRACE_ID: 'trace' } as NodeJS.ProcessEnv,
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(writeFile).toHaveBeenCalled()
})
it('refuses to configure IDE bindings from an SSH session', async () => {
const result = await configureDetectedTerminalKeybindings({
env: { SSH_CONNECTION: '1 2 3 4', TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('local machine')
})
it('prompts for setup when bindings are missing and suppresses prompt when complete', async () => {
const readMissing = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
await expect(
shouldPromptForTerminalSetup({
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
fileOps: { readFile: readMissing }
})
).resolves.toBe(true)
const readComplete = 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' }
}
])
)
await expect(
shouldPromptForTerminalSetup({
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
fileOps: { readFile: readComplete }
})
).resolves.toBe(false)
})
it('suppresses terminal setup prompts inside SSH sessions', async () => {
await expect(
shouldPromptForTerminalSetup({
env: { SSH_CONNECTION: '1 2 3 4', TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv
})
).resolves.toBe(false)
})
})

View file

@ -0,0 +1,15 @@
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)
})
it('rejects normal multiline or plain text paste', () => {
expect(looksLikeDroppedPath('hello world')).toBe(false)
expect(looksLikeDroppedPath('line one\nline two')).toBe(false)
})
})

View file

@ -3,6 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { ImageAttachResponse } from '../gatewayTypes.js'
import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import type {
@ -106,11 +107,13 @@ export interface ComposerPasteResult {
value: string
}
export type MaybePromise<T> = Promise<T> | T
export interface ComposerActions {
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
openEditor: () => void
pushHistory: (text: string) => void
replaceQueue: (index: number, text: string) => void
@ -146,6 +149,7 @@ export interface ComposerState {
export interface UseComposerStateOptions {
gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
onImageAttached?: (info: ImageAttachResponse) => void
submitRef: MutableRefObject<(value: string) => void>
}
@ -268,7 +272,7 @@ export interface AppLayoutComposerProps {
compIdx: number
completions: CompletionItem[]
empty: boolean
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
input: string
inputBuf: string[]
pagerPageSize: number

View file

@ -9,6 +9,7 @@ import type {
SessionUndoResponse
} from '../../../gatewayTypes.js'
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
@ -224,11 +225,40 @@ export const coreCommands: SlashCommand[] = [
},
{
help: 'paste clipboard image',
help: 'attach clipboard image',
name: 'paste',
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste())
},
{
help: 'configure IDE terminal keybindings for multiline + undo/redo',
name: 'terminal-setup',
run: (arg, ctx) => {
const target = arg.trim().toLowerCase()
if (target && !['auto', 'cursor', 'vscode', 'windsurf'].includes(target)) {
return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]')
}
const runner = !target || target === 'auto' ? configureDetectedTerminalKeybindings() : configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf')
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)}`)
}
})
}
},
{
help: 'view gateway logs',
name: 'logs',

View file

@ -1,4 +1,4 @@
import { imageTokenMeta, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import { introMsg, toTranscriptMessages, attachedImageNotice } from '../../../domain/messages.js'
import type {
BackgroundStartResponse,
BtwStartResponse,
@ -92,9 +92,7 @@ export const sessionCommands: SlashCommand[] = [
run: (arg, ctx) => {
ctx.gateway.rpc<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then(
ctx.guarded<ImageAttachResponse>(r => {
const meta = imageTokenMeta(r)
ctx.transcript.sys(`attached image: ${r.name ?? ''}${meta ? ` · ${meta}` : ''}`)
ctx.transcript.sys(attachedImageNotice(r))
if (r.remainder) {
ctx.composer.setInput(r.remainder)

View file

@ -5,16 +5,21 @@ import { join } from 'node:path'
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 { useCompletion } from '../hooks/useCompletion.js'
import { useInputHistory } from '../hooks/useInputHistory.js'
import { useQueue } from '../hooks/useQueue.js'
import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
import { readOsc52Clipboard } from '../lib/osc52.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
import type { InputDetectDropResponse } from '../gatewayTypes.js'
import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
import { $isBlocked } from './overlayStore.js'
import { getUiState } from './uiStore.js'
const PASTE_SNIP_MAX_COUNT = 32
const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024
@ -38,11 +43,34 @@ const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => {
return out.length === snips.length ? snips : out
}
export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult {
export function looksLikeDroppedPath(text: string): boolean {
const trimmed = text.trim()
if (!trimmed || trimmed.includes('\n')) {
return false
}
return (
trimmed.startsWith('/') ||
trimmed.startsWith('~') ||
trimmed.startsWith('./') ||
trimmed.startsWith('../') ||
trimmed.startsWith('file://') ||
trimmed.startsWith('"/') ||
trimmed.startsWith("'/") ||
trimmed.startsWith('"~') ||
trimmed.startsWith("'~") ||
(/^[A-Za-z]:[\\/]/.test(trimmed)) ||
(/^["'][A-Za-z]:[\\/]/.test(trimmed))
)
}
export function useComposerState({ gw, onClipboardPaste, onImageAttached, submitRef }: UseComposerStateOptions): UseComposerStateResult {
const [input, setInput] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([])
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
const isBlocked = useStore($isBlocked)
const { querier } = useStdin() as { querier: Parameters<typeof readOsc52Clipboard>[0] }
const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } =
useQueue()
@ -59,14 +87,8 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
historyDraftRef.current = ''
}, [historyDraftRef, setQueueEdit, setHistoryIdx])
const handleTextPaste = useCallback(
({ bracketed, cursor, hotkey, text, value }: PasteEvent) => {
if (hotkey) {
void onClipboardPaste(false)
return null
}
const handleResolvedPaste = useCallback(
async ({ bracketed, cursor, text, value }: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => {
const cleanedText = stripTrailingPasteNewlines(text)
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
@ -77,6 +99,55 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
return null
}
const sid = getUiState().sid
if (sid && looksLikeDroppedPath(cleanedText)) {
try {
const attached = await gw.request<InputDetectDropResponse & { remainder?: string }>('image.attach', {
path: cleanedText,
session_id: sid
})
if (attached?.name) {
onImageAttached?.(attached)
const remainder = attached.remainder?.trim() ?? ''
if (!remainder) {
return { cursor, value }
}
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
const insert = `${lead}${remainder}${tail}`
return {
cursor: cursor + insert.length,
value: value.slice(0, cursor) + insert + value.slice(cursor)
}
}
} catch {
// Fall back to generic file-drop detection below.
}
try {
const dropped = await gw.request<InputDetectDropResponse>('input.detect_drop', {
session_id: sid,
text: cleanedText
})
if (dropped?.matched && dropped.text) {
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
const insert = `${lead}${dropped.text}${tail}`
return {
cursor: cursor + insert.length,
value: value.slice(0, cursor) + insert + value.slice(cursor)
}
}
} catch {
// Fall through to normal text paste behavior.
}
}
const lineCount = cleanedText.split('\n').length
if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) {
@ -111,7 +182,40 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
value: value.slice(0, cursor) + insert + value.slice(cursor)
}
},
[gw, onClipboardPaste]
[gw, onClipboardPaste, onImageAttached]
)
const handleTextPaste = useCallback(
({ bracketed, cursor, hotkey, text, value }: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => {
if (hotkey) {
const preferOsc52 = Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT)
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)
})
return readPreferredText.then(async preferredText => {
if (isUsableClipboardText(preferredText)) {
return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value })
}
void onClipboardPaste(false)
return null
})
}
return handleResolvedPaste({ bracketed: !!bracketed, cursor, text, value })
},
[gw, handleResolvedPaste, onClipboardPaste, querier]
)
const openEditor = useCallback(() => {

View file

@ -4,7 +4,8 @@ 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 { 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 { type GatewayClient } from '../gatewayClient.js'
import type {
@ -117,6 +118,7 @@ export function useMainApp(gw: GatewayClient) {
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
const clipboardPasteRef = useRef<(quiet?: boolean) => Promise<void> | void>(() => {})
const submitRef = useRef<(value: string) => void>(() => {})
const terminalHintsShownRef = useRef(new Set<string>())
const historyItemsRef = useRef(historyItems)
const lastUserMsgRef = useRef(lastUserMsg)
const msgIdsRef = useRef(new WeakMap<Msg, string>())
@ -136,12 +138,30 @@ export function useMainApp(gw: GatewayClient) {
const composer = useComposerState({
gw,
onClipboardPaste: quiet => clipboardPasteRef.current(quiet),
onImageAttached: info => {
sys(attachedImageNotice(info))
},
submitRef
})
const { actions: composerActions, refs: composerRefs, state: composerState } = composer
const empty = !historyItems.some(msg => msg.kind !== 'intro')
useEffect(() => {
void terminalParityHints()
.then(hints => {
for (const hint of hints) {
if (terminalHintsShownRef.current.has(hint.key)) {
continue
}
terminalHintsShownRef.current.add(hint.key)
turnController.pushActivity(hint.message, hint.tone)
}
})
.catch(() => {})
}, [])
const messageId = useCallback((msg: Msg) => {
const hit = msgIdsRef.current.get(msg)

View file

@ -1,6 +1,6 @@
import { type MutableRefObject, useCallback, useRef } from 'react'
import { imageTokenMeta } from '../domain/messages.js'
import { attachedImageNotice } from '../domain/messages.js'
import { looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js'
@ -83,9 +83,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
}
if (r.is_image) {
const meta = imageTokenMeta(r)
turnController.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
turnController.pushActivity(attachedImageNotice(r))
} else {
turnController.pushActivity(`detected file: ${r.name}`)
}

View file

@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { setInputSelection } from '../app/inputSelectionStore.js'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
import { isActionMod, isMac } from '../lib/platform.js'
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
type InkExt = typeof Ink & {
stringWidth: (s: string) => number
@ -275,6 +275,11 @@ function useFwdDelete(active: boolean) {
return ref
}
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'
export function TextInput({
columns = 80,
value,
@ -298,6 +303,7 @@ export function TextInput({
const pasteEnd = useRef<null | number>(null)
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pastePos = useRef(0)
const editVersionRef = useRef(0)
const undo = useRef<{ cursor: number; value: string }[]>([])
const redo = useRef<{ cursor: number; value: string }[]>([])
@ -389,6 +395,7 @@ export function TextInput({
const commit = (next: string, nextCur: number, track = true) => {
const prev = vRef.current
const c = snapPos(next, nextCur)
editVersionRef.current += 1
if (selRef.current) {
selRef.current = null
@ -427,8 +434,21 @@ export function TextInput({
}
const emitPaste = (e: PasteEvent) => {
const startVersion = editVersionRef.current
const h = cbPaste.current?.(e)
if (isPasteResultPromise(h)) {
void h
.then(result => {
if (result && editVersionRef.current === startVersion) {
commit(result.value, result.cursor)
}
})
.catch(() => {})
return true
}
if (h) {
commit(h.value, h.cursor)
}
@ -506,7 +526,12 @@ export function TextInput({
(inp: string, k: Key, event: InputEvent) => {
const eventRaw = event.keypress.raw
if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16' || (isMac && k.meta && inp.toLowerCase() === 'v')) {
if (
eventRaw === '\x1bv' ||
eventRaw === '\x1bV' ||
eventRaw === '\x16' ||
(isMac && isActionMod(k) && inp.toLowerCase() === 'v')
) {
if (cbPaste.current) {
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
}
@ -522,7 +547,7 @@ export function TextInput({
return
}
if (isMac && k.meta && inp.toLowerCase() === 'c') {
if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') {
const range = selRange()
if (range) {
@ -548,7 +573,7 @@ export function TextInput({
}
if (k.return) {
k.shift || k.meta
k.shift || (isMac && isActionMod(k))
? commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
: cbSubmit.current?.(vRef.current)
@ -558,6 +583,9 @@ export function TextInput({
let c = curRef.current
let v = vRef.current
const mod = isActionMod(k)
const actionHome = k.home || isMacActionFallback(k, inp, 'a')
const actionEnd = k.end || (mod && inp === 'e') || isMacActionFallback(k, inp, 'e')
const actionDeleteToStart = (mod && inp === 'u') || isMacActionFallback(k, inp, 'u')
const range = selRange()
const delFwd = k.delete || fwdDel.current
@ -573,10 +601,10 @@ export function TextInput({
return selectAll()
}
if (k.home) {
if (actionHome) {
clearSel()
c = 0
} else if (k.end || (mod && inp === 'e')) {
} else if (actionEnd) {
clearSel()
c = v.length
} else if (k.leftArrow) {
@ -633,7 +661,7 @@ export function TextInput({
} else {
return
}
} else if (mod && inp === 'u') {
} else if (actionDeleteToStart) {
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
@ -742,7 +770,7 @@ interface TextInputProps {
focus?: boolean
mask?: string
onChange: (v: string) => void
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
onSubmit?: (v: string) => void
placeholder?: string
value: string

View file

@ -15,7 +15,7 @@ export const HOTKEYS: [string, string][] = [
[action + '+D', 'exit'],
[action + '+G', 'open $EDITOR for prompt'],
[action + '+L', 'new session (clear)'],
[paste + '+V / /paste', 'paste clipboard image'],
[paste + '+V / /paste', 'paste text; /paste attaches clipboard image'],
['Tab', 'apply completion'],
['↑/↓', 'completions / queue edit / history'],
[action + '+A/E', 'home / end of line'],

View file

@ -12,6 +12,13 @@ export const imageTokenMeta = (info?: ImageMeta | null) => {
.join(' · ')
}
export const attachedImageNotice = (info?: ({ name?: string } & ImageMeta) | null) => {
const meta = imageTokenMeta(info)
const label = info?.name ? `📎 Attached image: ${info.name}` : '📎 Attached image'
return `${label}${meta ? ` · ${meta}` : ''}`
}
export const userDisplay = (text: string) => {
if (text.length <= LONG_MSG) {
return text

View file

@ -2,29 +2,88 @@ import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const CLIPBOARD_MAX_BUFFER = 4 * 1024 * 1024
const POWERSHELL_ARGS = ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'] as const
type ClipboardRun = typeof execFileAsync
export function isUsableClipboardText(text: null | string): text is string {
if (!text || !/[^\s]/.test(text)) {
return false
}
if (text.includes('\u0000')) {
return false
}
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
}
}
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 }> {
if (platform === 'darwin') {
return [{ cmd: 'pbpaste', args: [] }]
}
if (platform === 'win32') {
return [{ cmd: 'powershell', args: POWERSHELL_ARGS }]
}
const attempts: Array<{ args: readonly string[]; cmd: string }> = []
if (env.WSL_INTEROP) {
attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS })
}
if (env.WAYLAND_DISPLAY) {
attempts.push({ cmd: 'wl-paste', args: ['--type', 'text'] })
}
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-out'] })
return attempts
}
/**
* Read plain text from the system clipboard.
*
* On macOS this uses `pbpaste`. On other platforms we intentionally return
* null for now; the TUI's text-paste hotkeys are primarily targeted at the
* macOS clarify/input flow.
* Uses native platform tools in fallback order:
* - macOS: pbpaste
* - Windows: PowerShell Get-Clipboard -Raw
* - WSL: powershell.exe Get-Clipboard -Raw
* - Linux Wayland: wl-paste --type text
* - Linux X11: xclip -selection clipboard -out
*/
export async function readClipboardText(
platform: NodeJS.Platform = process.platform,
run: typeof execFileAsync = execFileAsync
run: ClipboardRun = execFileAsync,
env: NodeJS.ProcessEnv = process.env
): Promise<string | null> {
if (platform !== 'darwin') {
return null
for (const attempt of readClipboardCommands(platform, env)) {
try {
const result = await run(attempt.cmd, [...attempt.args], {
encoding: 'utf8',
maxBuffer: CLIPBOARD_MAX_BUFFER,
windowsHide: true
})
if (typeof result.stdout === 'string') {
return result.stdout
}
} catch {
// Fall through to the next clipboard backend.
}
}
try {
const result = await run('pbpaste', [], { encoding: 'utf8', windowsHide: true })
return typeof result.stdout === 'string' ? result.stdout : null
} catch {
return null
}
return null
}
/**

View file

@ -1,2 +1,69 @@
const ESC = '\x1b'
const BEL = '\x07'
const ST = `${ESC}\\`
export const OSC52_CLIPBOARD_QUERY = `${ESC}]52;c;?${BEL}`
type OscResponse = { code: number; data: string; type: 'osc' }
type OscQuerier = {
flush: () => Promise<void>
send: <T>(query: { match: (r: unknown) => r is T; request: string }) => Promise<T | undefined>
}
function wrapForMultiplexer(sequence: string): string {
if (process.env['TMUX']) {
return `${ESC}Ptmux;${sequence.split(ESC).join(ESC + ESC)}${ST}`
}
if (process.env['STY']) {
return `${ESC}P${sequence}${ST}`
}
return sequence
}
export function buildOsc52ClipboardQuery(): string {
return wrapForMultiplexer(OSC52_CLIPBOARD_QUERY)
}
export function parseOsc52ClipboardData(data: string): null | string {
const firstSep = data.indexOf(';')
if (firstSep === -1) {
return null
}
const selection = data.slice(0, firstSep)
const payload = data.slice(firstSep + 1)
if ((selection !== 'c' && selection !== 'p') || !payload || payload === '?') {
return null
}
try {
return Buffer.from(payload, 'base64').toString('utf8')
} catch {
return null
}
}
export async function readOsc52Clipboard(querier: null | OscQuerier): Promise<null | string> {
if (!querier) {
return null
}
const response = await querier.send<OscResponse>({
request: buildOsc52ClipboardQuery(),
match: (r: unknown): r is OscResponse => {
return !!r && typeof r === 'object' && (r as OscResponse).type === 'osc' && (r as OscResponse).code === 52
}
})
await querier.flush()
return response ? parseOsc52ClipboardData(response.data) : null
}
export const writeOsc52Clipboard = (s: string) =>
process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`)

View file

@ -1,15 +1,32 @@
/** Platform-aware keybinding helpers.
*
* On macOS the "action" modifier is Cmd (key.meta in Ink), on other platforms
* it is Ctrl. Ctrl+C is ALWAYS the interrupt key regardless of platform it
* must never be remapped to copy.
* On macOS the "action" modifier is Cmd. Modern terminals that support kitty
* keyboard protocol report Cmd as `key.super`; legacy terminals often surface it
* as `key.meta`. Some macOS terminals also translate Cmd+Left/Right/Backspace
* into readline-style Ctrl+A/Ctrl+E/Ctrl+U before the app sees them.
* On other platforms the action modifier is Ctrl.
* Ctrl+C is ALWAYS the interrupt key regardless of platform it must never be
* remapped to copy.
*/
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 }): boolean => (isMac ? key.meta : key.ctrl)
export const isActionMod = (key: { ctrl: boolean; meta: boolean; super?: boolean }): boolean =>
(isMac ? key.meta || key.super === true : key.ctrl)
/**
* Some macOS terminals rewrite Cmd navigation/deletion into readline control keys.
* Treat those as action shortcuts too, but only for the specific fallbacks we
* have observed from terminals: Cmd+Left Ctrl+A, Cmd+Right Ctrl+E,
* Cmd+Backspace Ctrl+U.
*/
export const isMacActionFallback = (
key: { ctrl: boolean; meta: boolean; super?: boolean },
ch: string,
target: 'a' | 'e' | 'u'
): boolean => isMac && key.ctrl && !key.meta && key.super !== true && ch.toLowerCase() === target
/** Match action-modifier + a single character (case-insensitive). */
export const isAction = (key: { ctrl: boolean; meta: boolean }, ch: string, target: string): boolean =>
export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean =>
isActionMod(key) && ch.toLowerCase() === target

View file

@ -0,0 +1,64 @@
import { detectVSCodeLikeTerminal, shouldPromptForTerminalSetup } from './terminalSetup.js'
export type MacTerminalHint = {
key: string
message: string
tone: 'info' | 'warn'
}
export type MacTerminalContext = {
isAppleTerminal: boolean
isRemote: boolean
isTmux: boolean
vscodeLike: null | 'cursor' | 'vscode' | 'windsurf'
}
export function detectMacTerminalContext(env: NodeJS.ProcessEnv = process.env): MacTerminalContext {
const termProgram = env['TERM_PROGRAM'] ?? ''
return {
isAppleTerminal: termProgram === 'Apple_Terminal' || !!env['TERM_SESSION_ID'],
isRemote: !!(env['SSH_CONNECTION'] || env['SSH_TTY'] || env['SSH_CLIENT']),
isTmux: !!env['TMUX'],
vscodeLike: detectVSCodeLikeTerminal(env)
}
}
export async function terminalParityHints(env: NodeJS.ProcessEnv = process.env): Promise<MacTerminalHint[]> {
const ctx = detectMacTerminalContext(env)
const hints: MacTerminalHint[] = []
if (ctx.vscodeLike && (await shouldPromptForTerminalSetup({ env }))) {
hints.push({
key: 'ide-setup',
tone: 'info',
message: `Detected ${ctx.vscodeLike} terminal · run /terminal-setup for best Cmd+Enter / undo parity`
})
}
if (ctx.isAppleTerminal) {
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'
})
}
if (ctx.isTmux) {
hints.push({
key: 'tmux',
tone: 'warn',
message: 'tmux detected · clipboard copy/paste uses passthrough when available; allow-passthrough improves OSC52 reliability'
})
}
if (ctx.isRemote) {
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'
})
}
return hints
}

View file

@ -0,0 +1,278 @@
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
export type SupportedTerminal = 'cursor' | 'vscode' | 'windsurf'
type FileOps = {
copyFile: typeof copyFile
mkdir: typeof mkdir
readFile: typeof readFile
writeFile: typeof writeFile
}
type Keybinding = {
args?: { text?: string }
command?: string
key?: string
when?: string
}
export type TerminalSetupResult = {
message: string
requiresRestart?: boolean
success: boolean
}
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' },
windsurf: { appName: 'Windsurf', label: 'Windsurf' }
}
const TARGET_BINDINGS: Keybinding[] = [
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: MULTILINE_SEQUENCE }
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: MULTILINE_SEQUENCE }
},
{
key: 'cmd+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: MULTILINE_SEQUENCE }
},
{
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' }
}
]
export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? ''
if (env['CURSOR_TRACE_ID'] || askpass.includes('cursor')) {
return 'cursor'
}
if (askpass.includes('windsurf')) {
return 'windsurf'
}
if (env['TERM_PROGRAM'] === 'vscode' || env['VSCODE_GIT_IPC_HANDLE']) {
return 'vscode'
}
return null
}
export function stripJsonComments(content: string): string {
return content.replace(/^\s*\/\/.*$/gm, '')
}
function isRemoteShellSession(env: NodeJS.ProcessEnv): boolean {
return Boolean(env['SSH_CONNECTION'] || env['SSH_TTY'] || env['SSH_CLIENT'])
}
export function getVSCodeStyleConfigDir(
appName: string,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
homeDir: string = homedir()
): null | string {
if (platform === 'darwin') {
return join(homeDir, 'Library', 'Application Support', appName, 'User')
}
if (platform === 'win32') {
return env['APPDATA'] ? join(env['APPDATA'], appName, 'User') : null
}
return join(homeDir, '.config', appName, 'User')
}
function isKeybinding(value: unknown): value is Keybinding {
return typeof value === 'object' && value !== null
}
function sameBinding(a: Keybinding, b: Keybinding): boolean {
return a.key === b.key && a.command === b.command && a.when === b.when && a.args?.text === b.args?.text
}
async function backupFile(filePath: string, ops: FileOps): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
}
export async function configureTerminalKeybindings(
terminal: SupportedTerminal,
options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
now?: () => Date
platform?: NodeJS.Platform
}
): Promise<TerminalSetupResult> {
const env = options?.env ?? process.env
const platform = options?.platform ?? process.platform
const homeDir = options?.homeDir ?? homedir()
const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) }
const meta = TERMINAL_META[terminal]
if (isRemoteShellSession(env)) {
return {
success: false,
message: `${meta.label} terminal setup must be run on the local machine, not inside an SSH session.`
}
}
const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir)
if (!configDir) {
return {
success: false,
message: `Could not determine ${meta.label} settings path on this platform.`
}
}
const keybindingsFile = join(configDir, 'keybindings.json')
try {
await ops.mkdir(configDir, { recursive: true })
let keybindings: unknown[] = []
try {
const content = await ops.readFile(keybindingsFile, 'utf8')
await backupFile(keybindingsFile, ops)
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,
message: `Failed to read ${meta.label} keybindings: ${error}`
}
}
}
const conflicts = TARGET_BINDINGS.filter(target =>
keybindings.some(existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target))
)
if (conflicts.length) {
return {
success: false,
message:
`Existing terminal keybindings would conflict in ${keybindingsFile}: ` + conflicts.map(c => c.key).join(', ')
}
}
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
}
}
if (!added) {
return {
success: true,
message: `${meta.label} terminal keybindings already configured.`
}
}
await ops.writeFile(keybindingsFile, `${JSON.stringify(keybindings, null, 2)}\n`, 'utf8')
return {
success: true,
requiresRestart: true,
message: `Added ${added} ${meta.label} terminal keybinding${added === 1 ? '' : 's'} in ${keybindingsFile}`
}
} catch (error) {
return {
success: false,
message: `Failed to configure ${meta.label} terminal shortcuts: ${error}`
}
}
}
export async function configureDetectedTerminalKeybindings(options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
platform?: NodeJS.Platform
}): Promise<TerminalSetupResult> {
const detected = detectVSCodeLikeTerminal(options?.env ?? process.env)
if (!detected) {
return {
success: false,
message: 'No supported IDE terminal detected. Supported: VS Code, Cursor, Windsurf.'
}
}
return configureTerminalKeybindings(detected, options)
}
export async function shouldPromptForTerminalSetup(options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
platform?: NodeJS.Platform
}): Promise<boolean> {
const env = options?.env ?? process.env
const detected = detectVSCodeLikeTerminal(env)
if (!detected || isRemoteShellSession(env)) {
return false
}
const platform = options?.platform ?? process.platform
const homeDir = options?.homeDir ?? homedir()
const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) }
const meta = TERMINAL_META[detected]
const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir)
if (!configDir) {
return false
}
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)))
} catch {
return true
}
}

View file

@ -4,6 +4,7 @@ declare module '@hermes/ink' {
export type Key = {
readonly ctrl: boolean
readonly meta: boolean
readonly super: boolean
readonly shift: boolean
readonly alt: boolean
readonly upArrow: boolean

View file

@ -78,9 +78,10 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/usage` | Show token usage, cost breakdown, and session duration |
| `/insights` | Show usage insights and analytics (last 30 days) |
| `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status |
| `/paste` | Check clipboard for an image and attach it |
| `/paste` | Attach a clipboard image |
| `/copy [number]` | Copy the last assistant response to clipboard (or the Nth-from-last with a number). CLI-only. |
| `/image <path>` | Attach a local image file for your next prompt. |
| `/terminal-setup [auto\|vscode\|cursor\|windsurf]` | TUI-only: configure local VS Code-family terminal bindings for better multiline + undo/redo parity. |
| `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. |
| `/profile` | Show active profile name and home directory |
| `/gquota` | Show Google Gemini Code Assist quota usage with progress bars (only available when the `google-gemini-cli` provider is active). |
@ -157,7 +158,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
## Notes
- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, and `/plugins` are **CLI-only** commands.
- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/terminal-setup`, `/statusbar`, and `/plugins` are **CLI-only** commands.
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, and `/commands` are **messaging-only** commands.
- `/status`, `/background`, `/voice`, `/reload-mcp`, `/rollback`, `/snapshot`, `/debug`, `/fast`, and `/yolo` work in **both** the CLI and the messaging gateway.

View file

@ -27,50 +27,52 @@ How you attach an image depends on your terminal environment. Not all methods wo
### `/paste` Command
**The most reliable method. Works everywhere.**
**The most reliable explicit image-attach fallback.**
```
/paste
```
Type `/paste` and press Enter. Hermes checks your clipboard for an image and attaches it. This works in every environment because it explicitly calls the clipboard backend — no terminal keybinding interception to worry about.
Type `/paste` and press Enter. Hermes checks your clipboard for an image and attaches it. This is the safest option when your terminal rewrites `Cmd+V`/`Ctrl+V`, or when you copied only an image and there is no bracketed-paste text payload to inspect.
### Ctrl+V / Cmd+V (Bracketed Paste)
### Ctrl+V / Cmd+V
When you paste text that's on the clipboard alongside an image, Hermes automatically checks for an image too. This works when:
- Your clipboard contains **both text and an image** (some apps put both on the clipboard when you copy)
- Your terminal supports bracketed paste (most modern terminals do)
Hermes now treats paste as a layered flow:
- normal text paste first
- native clipboard / OSC52 text fallback if the terminal did not deliver text cleanly
- image attach when the clipboard or pasted payload resolves to an image or image path
This means pasted macOS screenshot temp paths and `file://...` image URIs can attach immediately instead of sitting in the composer as raw text.
:::warning
If your clipboard has **only an image** (no text), Ctrl+V does nothing in most terminals. Terminals can only paste text — there's no standard mechanism to paste binary image data. Use `/paste` or Alt+V instead.
If your clipboard has **only an image** (no text), terminals still cannot send binary image bytes directly. Use `/paste` as the explicit image-attach fallback.
:::
### Alt+V
### `/terminal-setup` for VS Code / Cursor / Windsurf
Alt key combinations pass through most terminal emulators (they're sent as ESC + key rather than being intercepted). Press `Alt+V` to check the clipboard for an image.
If you run the TUI inside a local VS Code-family integrated terminal on macOS, Hermes can install the recommended `workbench.action.terminal.sendSequence` bindings for better multiline and undo/redo parity:
:::caution
**Does not work in VSCode's integrated terminal.** VSCode intercepts many Alt+key combos for its own UI. Use `/paste` instead.
:::
```text
/terminal-setup
```
### Ctrl+V (Raw — Linux Only)
On Linux desktop terminals (GNOME Terminal, Konsole, Alacritty, etc.), `Ctrl+V` is **not** the paste shortcut — `Ctrl+Shift+V` is. So `Ctrl+V` sends a raw byte to the application, and Hermes catches it to check the clipboard. This only works on Linux desktop terminals with X11 or Wayland clipboard access.
This is especially useful when `Cmd+Enter`, `Cmd+Z`, or `Shift+Cmd+Z` are being intercepted by the IDE. Run it on the local machine only — not inside an SSH session.
## Platform Compatibility
| Environment | `/paste` | Ctrl+V text+image | Alt+V | Notes |
| Environment | `/paste` | Cmd/Ctrl+V | `/terminal-setup` | Notes |
|---|:---:|:---:|:---:|---|
| **macOS Terminal / iTerm2** | ✅ | ✅ | ✅ | Best experience — `osascript` always available |
| **Linux X11 desktop** | ✅ | ✅ | ✅ | Requires `xclip` (`apt install xclip`) |
| **Linux Wayland desktop** | ✅ | ✅ | ✅ | Requires `wl-paste` (`apt install wl-clipboard`) |
| **WSL2 (Windows Terminal)** | ✅ | ✅¹ | ✅ | Uses `powershell.exe` — no extra install needed |
| **VSCode Terminal (local)** | ✅ | ✅¹ | ❌ | VSCode intercepts Alt+key |
| **VSCode Terminal (SSH)** | ❌² | ❌² | ❌ | Remote clipboard not accessible |
| **SSH terminal (any)** | ❌² | ❌² | ❌² | Remote clipboard not accessible |
| **macOS Terminal / iTerm2** | ✅ | ✅ | n/a | Best experience — native clipboard + screenshot-path recovery |
| **Apple Terminal** | ✅ | ✅ | n/a | If Cmd+←/→/⌫ gets rewritten, use Ctrl+A / Ctrl+E / Ctrl+U fallbacks |
| **Linux X11 desktop** | ✅ | ✅ | n/a | Requires `xclip` (`apt install xclip`) |
| **Linux Wayland desktop** | ✅ | ✅ | n/a | Requires `wl-paste` (`apt install wl-clipboard`) |
| **WSL2 (Windows Terminal)** | ✅ | ✅ | n/a | Uses `powershell.exe` — no extra install needed |
| **VS Code / Cursor / Windsurf (local)** | ✅ | ✅ | ✅ | Recommended for better Cmd+Enter / undo / redo parity |
| **VS Code / Cursor / Windsurf (SSH)** | ❌² | ❌² | ❌³ | Run `/terminal-setup` on the local machine instead |
| **SSH terminal (any)** | ❌² | ❌² | n/a | Remote clipboard not accessible |
¹ Only when clipboard has both text and an image (image-only clipboard = nothing happens)
² See [SSH & Remote Sessions](#ssh--remote-sessions) below
³ The command writes local IDE keybindings and should not be run from the remote host
## Platform-Specific Setup
@ -145,7 +147,9 @@ powershell.exe -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms;
## SSH & Remote Sessions
**Clipboard paste does not work over SSH.** When you SSH into a remote machine, the Hermes CLI runs on the remote host. All clipboard tools (`xclip`, `wl-paste`, `powershell.exe`, `osascript`) read the clipboard of the machine they run on — which is the remote server, not your local machine. Your local clipboard is inaccessible from the remote side.
**Clipboard image paste does not fully work over SSH.** When you SSH into a remote machine, the Hermes CLI runs on the remote host. Clipboard tools (`xclip`, `wl-paste`, `powershell.exe`, `osascript`) read the clipboard of the machine they run on — which is the remote server, not your local machine. Your local clipboard image is therefore inaccessible from the remote side.
Text can sometimes still bridge through terminal paste or OSC52, but image clipboard access and local screenshot temp paths remain tied to the machine running Hermes.
### Workarounds for SSH

View file

@ -46,7 +46,7 @@ The classic CLI remains available as the default. Anything documented in [CLI In
- **Live session panel** — tools and skills fill in progressively as they initialize.
- **Mouse-friendly selection** — drag to highlight with a uniform background instead of SGR inverse. Copy with your terminal's normal copy gesture.
- **Alternate-screen rendering** — differential updates mean no flicker when streaming, no scrollback clutter after you quit.
- **Composer affordances** — inline paste-collapse for long snippets, image paste from the clipboard (`Alt+V`), bracketed-paste safety.
- **Composer affordances** — inline paste-collapse for long snippets, `Cmd+V` / `Ctrl+V` text paste with clipboard-image fallback, bracketed-paste safety, and image/file-path attachment normalization.
Same [skins](features/skins.md) and [personalities](features/personality.md) apply. Switch mid-session with `/skin ares`, `/personality pirate`, and the UI repaints live. See [Skins & Themes](features/skins.md) for the full list of customizable keys and which ones apply to classic vs TUI — the TUI honors the banner palette, UI colors, prompt glyph/color, session display, completion menu, selection bg, `tool_prefix`, and `help_header`.
@ -73,7 +73,8 @@ The directory must contain `dist/entry.js` and an up-to-date `node_modules`.
Keybindings match the [Classic CLI](cli.md#keybindings) exactly. The only behavioral differences:
- **Mouse drag** highlights text with a uniform selection background.
- **`Ctrl+V`** pastes text from your clipboard directly into the composer; multi-line pastes stay on one row until you expand them.
- **`Cmd+V` / `Ctrl+V`** first tries normal text paste, then falls back to OSC52/native clipboard reads, and finally image attach when the clipboard or pasted payload resolves to an image.
- **`/terminal-setup`** installs local VS Code / Cursor / Windsurf terminal bindings for better `Cmd+Enter` and undo/redo parity on macOS.
- **Slash autocompletion** opens as a floating panel with descriptions, not an inline dropdown.
## Slash commands