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 time
import uuid import uuid
import textwrap import textwrap
from urllib.parse import unquote, urlparse
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from datetime import datetime 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("'")): if (token.startswith('"') and token.endswith('"')) or (token.startswith("'") and token.endswith("'")):
token = token[1:-1].strip() token = token[1:-1].strip()
token = token.replace('\\ ', ' ')
if not token: if not token:
return None 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": if os.name != "nt":
normalized = expanded.replace("\\", "/") normalized = expanded.replace("\\", "/")
if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha(): 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("./")
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 (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha())
or stripped.startswith('"/') or stripped.startswith('"/')
or stripped.startswith('"~') or stripped.startswith('"~')
@ -1371,8 +1384,25 @@ def _detect_file_drop(user_input: str) -> "dict | None":
if not starts_like_path: if not starts_like_path:
return None 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) first_token, remainder = _split_path_input(stripped)
drop_path = _resolve_attachment_path(first_token) 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: if drop_path is None:
return None return None

View file

@ -147,6 +147,37 @@ class TestEscapedSpaces:
assert result["path"] == tmp_image_with_spaces assert result["path"] == tmp_image_with_spaces
assert result["remainder"] == "what is this?" 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): def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
home = tmp_path / "home" home = tmp_path / "home"
img = home / "storage" / "shared" / "Pictures" / "cat.png" img = home / "storage" / "shared" / "Pictures" / "cat.png"

View file

@ -147,6 +147,27 @@ class TestEscapedSpaces:
assert result["path"] == tmp_image_with_spaces assert result["path"] == tmp_image_with_spaces
assert result["remainder"] == "what is this?" 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 # 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): def test_image_attach_appends_local_image(monkeypatch):
fake_cli = types.ModuleType("cli") fake_cli = types.ModuleType("cli")
fake_cli._IMAGE_EXTENSIONS = {".png"} 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._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: Path("/tmp/cat.png") 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 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): def test_commands_catalog_surfaces_quick_commands(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": { monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {
"build": {"type": "exec", "command": "npm run build"}, "build": {"type": "exec", "command": "npm run build"},

View file

@ -1644,12 +1644,17 @@ def _(rid, params: dict) -> dict:
if not raw: if not raw:
return _err(rid, 4015, "path required") return _err(rid, 4015, "path required")
try: 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) dropped = _detect_file_drop(raw)
image_path = _resolve_attachment_path(path_token) if dropped:
if image_path is None: image_path = dropped["path"]
return _err(rid, 4016, f"image not found: {path_token}") 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: if image_path.suffix.lower() not in _IMAGE_EXTENSIONS:
return _err(rid, 4016, f"unsupported image: {image_path.name}") return _err(rid, 4016, f"unsupported image: {image_path.name}")
session.setdefault("attached_images", []).append(str(image_path)) 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+D` | Exit |
| `Ctrl+G` | Open `$EDITOR` with the current draft | | `Ctrl+G` | Open `$EDITOR` with the current draft |
| `Ctrl+L` | New session (same as `/clear`) | | `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 | | `Tab` | Apply the active completion |
| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history | | `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history |
| `Left/Right` | Move the cursor | | `Left/Right` | Move the cursor |
@ -217,8 +217,8 @@ The local slash handler covers the built-ins that need direct client behavior:
Notes: Notes:
- `/copy` sends the selected assistant response through OSC 52. - `/copy` sends the selected assistant response through OSC 52.
- `/paste` with no args asks the gateway for clipboard image attachment state. - `/paste` with no args asks the gateway to attach a clipboard image.
- `/paste` does not manage text paste entries; text paste is inline-only. - 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. - `/details [hidden|collapsed|expanded|cycle]` controls thinking/tool-detail visibility.
- `/statusbar` toggles the status rule on/off. - `/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 { describe, expect, it, vi } from 'vitest'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js' import { isUsableClipboardText, readClipboardText, writeClipboardText } from '../lib/clipboard.js'
describe('readClipboardText', () => { 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 () => { it('reads text from pbpaste on macOS', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'hello world\n' }) const run = vi.fn().mockResolvedValue({ stdout: 'hello world\n' })
await expect(readClipboardText('darwin', run)).resolves.toBe('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 () => { it('reads text from PowerShell on Windows', async () => {
const run = vi.fn().mockRejectedValue(new Error('pbpaste failed')) 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 { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js' import type { GatewayClient } from '../gatewayClient.js'
import type { ImageAttachResponse } from '../gatewayTypes.js'
import type { RpcResult } from '../lib/rpc.js' import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import type { import type {
@ -106,11 +107,13 @@ export interface ComposerPasteResult {
value: string value: string
} }
export type MaybePromise<T> = Promise<T> | T
export interface ComposerActions { export interface ComposerActions {
clearIn: () => void clearIn: () => void
dequeue: () => string | undefined dequeue: () => string | undefined
enqueue: (text: string) => void enqueue: (text: string) => void
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
openEditor: () => void openEditor: () => void
pushHistory: (text: string) => void pushHistory: (text: string) => void
replaceQueue: (index: number, text: string) => void replaceQueue: (index: number, text: string) => void
@ -146,6 +149,7 @@ export interface ComposerState {
export interface UseComposerStateOptions { export interface UseComposerStateOptions {
gw: GatewayClient gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void onClipboardPaste: (quiet?: boolean) => Promise<void> | void
onImageAttached?: (info: ImageAttachResponse) => void
submitRef: MutableRefObject<(value: string) => void> submitRef: MutableRefObject<(value: string) => void>
} }
@ -268,7 +272,7 @@ export interface AppLayoutComposerProps {
compIdx: number compIdx: number
completions: CompletionItem[] completions: CompletionItem[]
empty: boolean empty: boolean
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
input: string input: string
inputBuf: string[] inputBuf: string[]
pagerPageSize: number pagerPageSize: number

View file

@ -9,6 +9,7 @@ import type {
SessionUndoResponse SessionUndoResponse
} from '../../../gatewayTypes.js' } from '../../../gatewayTypes.js'
import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
import type { DetailsMode, Msg, PanelSection } from '../../../types.js' import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
import { patchOverlayState } from '../../overlayStore.js' import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js' import { patchUiState } from '../../uiStore.js'
@ -224,11 +225,40 @@ export const coreCommands: SlashCommand[] = [
}, },
{ {
help: 'paste clipboard image', help: 'attach clipboard image',
name: 'paste', name: 'paste',
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.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', help: 'view gateway logs',
name: '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 { import type {
BackgroundStartResponse, BackgroundStartResponse,
BtwStartResponse, BtwStartResponse,
@ -92,9 +92,7 @@ export const sessionCommands: SlashCommand[] = [
run: (arg, ctx) => { run: (arg, ctx) => {
ctx.gateway.rpc<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then( ctx.gateway.rpc<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then(
ctx.guarded<ImageAttachResponse>(r => { ctx.guarded<ImageAttachResponse>(r => {
const meta = imageTokenMeta(r) ctx.transcript.sys(attachedImageNotice(r))
ctx.transcript.sys(`attached image: ${r.name ?? ''}${meta ? ` · ${meta}` : ''}`)
if (r.remainder) { if (r.remainder) {
ctx.composer.setInput(r.remainder) ctx.composer.setInput(r.remainder)

View file

@ -5,16 +5,21 @@ import { join } from 'node:path'
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 { 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'
import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
import { readOsc52Clipboard } from '../lib/osc52.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.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 { $isBlocked } from './overlayStore.js'
import { getUiState } from './uiStore.js'
const PASTE_SNIP_MAX_COUNT = 32 const PASTE_SNIP_MAX_COUNT = 32
const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024 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 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 [input, setInput] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([]) const [inputBuf, setInputBuf] = useState<string[]>([])
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([]) const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
const isBlocked = useStore($isBlocked) const isBlocked = useStore($isBlocked)
const { querier } = useStdin() as { querier: Parameters<typeof readOsc52Clipboard>[0] }
const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } =
useQueue() useQueue()
@ -59,14 +87,8 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
historyDraftRef.current = '' historyDraftRef.current = ''
}, [historyDraftRef, setQueueEdit, setHistoryIdx]) }, [historyDraftRef, setQueueEdit, setHistoryIdx])
const handleTextPaste = useCallback( const handleResolvedPaste = useCallback(
({ bracketed, cursor, hotkey, text, value }: PasteEvent) => { async ({ bracketed, cursor, text, value }: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => {
if (hotkey) {
void onClipboardPaste(false)
return null
}
const cleanedText = stripTrailingPasteNewlines(text) const cleanedText = stripTrailingPasteNewlines(text)
if (!cleanedText || !/[^\n]/.test(cleanedText)) { if (!cleanedText || !/[^\n]/.test(cleanedText)) {
@ -77,6 +99,55 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
return null 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 const lineCount = cleanedText.split('\n').length
if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { 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) 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(() => { 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 { 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 { 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 {
@ -117,6 +118,7 @@ export function useMainApp(gw: GatewayClient) {
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
const clipboardPasteRef = useRef<(quiet?: boolean) => Promise<void> | void>(() => {}) const clipboardPasteRef = useRef<(quiet?: boolean) => Promise<void> | void>(() => {})
const submitRef = useRef<(value: string) => void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {})
const terminalHintsShownRef = useRef(new Set<string>())
const historyItemsRef = useRef(historyItems) const historyItemsRef = useRef(historyItems)
const lastUserMsgRef = useRef(lastUserMsg) const lastUserMsgRef = useRef(lastUserMsg)
const msgIdsRef = useRef(new WeakMap<Msg, string>()) const msgIdsRef = useRef(new WeakMap<Msg, string>())
@ -136,12 +138,30 @@ export function useMainApp(gw: GatewayClient) {
const composer = useComposerState({ const composer = useComposerState({
gw, gw,
onClipboardPaste: quiet => clipboardPasteRef.current(quiet), onClipboardPaste: quiet => clipboardPasteRef.current(quiet),
onImageAttached: info => {
sys(attachedImageNotice(info))
},
submitRef submitRef
}) })
const { actions: composerActions, refs: composerRefs, state: composerState } = composer const { actions: composerActions, refs: composerRefs, state: composerState } = composer
const empty = !historyItems.some(msg => msg.kind !== 'intro') 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 messageId = useCallback((msg: Msg) => {
const hit = msgIdsRef.current.get(msg) const hit = msgIdsRef.current.get(msg)

View file

@ -1,6 +1,6 @@
import { type MutableRefObject, useCallback, useRef } from 'react' 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 { looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js' import type { GatewayClient } from '../gatewayClient.js'
import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js'
@ -83,9 +83,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
} }
if (r.is_image) { if (r.is_image) {
const meta = imageTokenMeta(r) turnController.pushActivity(attachedImageNotice(r))
turnController.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
} else { } else {
turnController.pushActivity(`detected file: ${r.name}`) 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 { setInputSelection } from '../app/inputSelectionStore.js'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.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 & { type InkExt = typeof Ink & {
stringWidth: (s: string) => number stringWidth: (s: string) => number
@ -275,6 +275,11 @@ function useFwdDelete(active: boolean) {
return ref 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({ export function TextInput({
columns = 80, columns = 80,
value, value,
@ -298,6 +303,7 @@ export function TextInput({
const pasteEnd = useRef<null | number>(null) const pasteEnd = useRef<null | number>(null)
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pastePos = useRef(0) const pastePos = useRef(0)
const editVersionRef = useRef(0)
const undo = useRef<{ cursor: number; value: string }[]>([]) const undo = useRef<{ cursor: number; value: string }[]>([])
const redo = 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 commit = (next: string, nextCur: number, track = true) => {
const prev = vRef.current const prev = vRef.current
const c = snapPos(next, nextCur) const c = snapPos(next, nextCur)
editVersionRef.current += 1
if (selRef.current) { if (selRef.current) {
selRef.current = null selRef.current = null
@ -427,8 +434,21 @@ export function TextInput({
} }
const emitPaste = (e: PasteEvent) => { const emitPaste = (e: PasteEvent) => {
const startVersion = editVersionRef.current
const h = cbPaste.current?.(e) 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) { if (h) {
commit(h.value, h.cursor) commit(h.value, h.cursor)
} }
@ -506,7 +526,12 @@ export function TextInput({
(inp: string, k: Key, event: InputEvent) => { (inp: string, k: Key, event: InputEvent) => {
const eventRaw = event.keypress.raw 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) { if (cbPaste.current) {
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
} }
@ -522,7 +547,7 @@ export function TextInput({
return return
} }
if (isMac && k.meta && inp.toLowerCase() === 'c') { if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') {
const range = selRange() const range = selRange()
if (range) { if (range) {
@ -548,7 +573,7 @@ export function TextInput({
} }
if (k.return) { if (k.return) {
k.shift || k.meta k.shift || (isMac && isActionMod(k))
? commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1) ? commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
: cbSubmit.current?.(vRef.current) : cbSubmit.current?.(vRef.current)
@ -558,6 +583,9 @@ export function TextInput({
let c = curRef.current let c = curRef.current
let v = vRef.current let v = vRef.current
const mod = isActionMod(k) 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 range = selRange()
const delFwd = k.delete || fwdDel.current const delFwd = k.delete || fwdDel.current
@ -573,10 +601,10 @@ export function TextInput({
return selectAll() return selectAll()
} }
if (k.home) { if (actionHome) {
clearSel() clearSel()
c = 0 c = 0
} else if (k.end || (mod && inp === 'e')) { } else if (actionEnd) {
clearSel() clearSel()
c = v.length c = v.length
} else if (k.leftArrow) { } else if (k.leftArrow) {
@ -633,7 +661,7 @@ export function TextInput({
} else { } else {
return return
} }
} else if (mod && inp === 'u') { } else if (actionDeleteToStart) {
if (range) { if (range) {
v = v.slice(0, range.start) + v.slice(range.end) v = v.slice(0, range.start) + v.slice(range.end)
c = range.start c = range.start
@ -742,7 +770,7 @@ 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 } | null onPaste?: (e: PasteEvent) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
onSubmit?: (v: string) => void onSubmit?: (v: string) => void
placeholder?: string placeholder?: string
value: string value: string

View file

@ -15,7 +15,7 @@ export const HOTKEYS: [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)'],
[paste + '+V / /paste', 'paste clipboard image'], [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'],
['Tab', 'apply completion'], ['Tab', 'apply completion'],
['↑/↓', 'completions / queue edit / history'], ['↑/↓', 'completions / queue edit / history'],
[action + '+A/E', 'home / end of line'], [action + '+A/E', 'home / end of line'],

View file

@ -12,6 +12,13 @@ export const imageTokenMeta = (info?: ImageMeta | null) => {
.join(' · ') .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) => { export const userDisplay = (text: string) => {
if (text.length <= LONG_MSG) { if (text.length <= LONG_MSG) {
return text return text

View file

@ -2,29 +2,88 @@ import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
const execFileAsync = promisify(execFile) 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. * Read plain text from the system clipboard.
* *
* On macOS this uses `pbpaste`. On other platforms we intentionally return * Uses native platform tools in fallback order:
* null for now; the TUI's text-paste hotkeys are primarily targeted at the * - macOS: pbpaste
* macOS clarify/input flow. * - 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( export async function readClipboardText(
platform: NodeJS.Platform = process.platform, platform: NodeJS.Platform = process.platform,
run: typeof execFileAsync = execFileAsync run: ClipboardRun = execFileAsync,
env: NodeJS.ProcessEnv = process.env
): Promise<string | null> { ): Promise<string | null> {
if (platform !== 'darwin') { for (const attempt of readClipboardCommands(platform, env)) {
return null 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 { return null
const result = await run('pbpaste', [], { encoding: 'utf8', windowsHide: true })
return typeof result.stdout === 'string' ? result.stdout : null
} catch {
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) => export const writeOsc52Clipboard = (s: string) =>
process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`) process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`)

View file

@ -1,15 +1,32 @@
/** Platform-aware keybinding helpers. /** Platform-aware keybinding helpers.
* *
* On macOS the "action" modifier is Cmd (key.meta in Ink), on other platforms * On macOS the "action" modifier is Cmd. Modern terminals that support kitty
* it is Ctrl. Ctrl+C is ALWAYS the interrupt key regardless of platform it * keyboard protocol report Cmd as `key.super`; legacy terminals often surface it
* must never be remapped to copy. * 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' 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 }): 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). */ /** 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 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 = { export type Key = {
readonly ctrl: boolean readonly ctrl: boolean
readonly meta: boolean readonly meta: boolean
readonly super: boolean
readonly shift: boolean readonly shift: boolean
readonly alt: boolean readonly alt: boolean
readonly upArrow: 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 | | `/usage` | Show token usage, cost breakdown, and session duration |
| `/insights` | Show usage insights and analytics (last 30 days) | | `/insights` | Show usage insights and analytics (last 30 days) |
| `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status | | `/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. | | `/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. | | `/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. | | `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. |
| `/profile` | Show active profile name and home directory | | `/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). | | `/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 ## 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. - `/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. - `/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. - `/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 ### `/paste` Command
**The most reliable method. Works everywhere.** **The most reliable explicit image-attach fallback.**
``` ```
/paste /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: Hermes now treats paste as a layered flow:
- Your clipboard contains **both text and an image** (some apps put both on the clipboard when you copy) - normal text paste first
- Your terminal supports bracketed paste (most modern terminals do) - 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 :::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 ```text
**Does not work in VSCode's integrated terminal.** VSCode intercepts many Alt+key combos for its own UI. Use `/paste` instead. /terminal-setup
::: ```
### Ctrl+V (Raw — Linux Only) 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.
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.
## Platform Compatibility ## 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 | | **macOS Terminal / iTerm2** | ✅ | ✅ | n/a | Best experience — native clipboard + screenshot-path recovery |
| **Linux X11 desktop** | ✅ | ✅ | ✅ | Requires `xclip` (`apt install xclip`) | | **Apple Terminal** | ✅ | ✅ | n/a | If Cmd+←/→/⌫ gets rewritten, use Ctrl+A / Ctrl+E / Ctrl+U fallbacks |
| **Linux Wayland desktop** | ✅ | ✅ | ✅ | Requires `wl-paste` (`apt install wl-clipboard`) | | **Linux X11 desktop** | ✅ | ✅ | n/a | Requires `xclip` (`apt install xclip`) |
| **WSL2 (Windows Terminal)** | ✅ | ✅¹ | ✅ | Uses `powershell.exe` — no extra install needed | | **Linux Wayland desktop** | ✅ | ✅ | n/a | Requires `wl-paste` (`apt install wl-clipboard`) |
| **VSCode Terminal (local)** | ✅ | ✅¹ | ❌ | VSCode intercepts Alt+key | | **WSL2 (Windows Terminal)** | ✅ | ✅ | n/a | Uses `powershell.exe` — no extra install needed |
| **VSCode Terminal (SSH)** | ❌² | ❌² | ❌ | Remote clipboard not accessible | | **VS Code / Cursor / Windsurf (local)** | ✅ | ✅ | ✅ | Recommended for better Cmd+Enter / undo / redo parity |
| **SSH terminal (any)** | ❌² | ❌² | ❌² | Remote clipboard not accessible | | **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 ² 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 ## Platform-Specific Setup
@ -145,7 +147,9 @@ powershell.exe -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms;
## SSH & Remote Sessions ## 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 ### 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. - **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. - **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. - **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`. 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: Keybindings match the [Classic CLI](cli.md#keybindings) exactly. The only behavioral differences:
- **Mouse drag** highlights text with a uniform selection background. - **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 autocompletion** opens as a floating panel with descriptions, not an inline dropdown.
## Slash commands ## Slash commands