diff --git a/cli.py b/cli.py index 624139076..aec48aef7 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/tests/cli/test_cli_file_drop.py b/tests/cli/test_cli_file_drop.py index 78503de8d..fa6aac1ed 100644 --- a/tests/cli/test_cli_file_drop.py +++ b/tests/cli/test_cli_file_drop.py @@ -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" diff --git a/tests/test_cli_file_drop.py b/tests/test_cli_file_drop.py index 386aba5d1..5161e435f 100644 --- a/tests/test_cli_file_drop.py +++ b/tests/test_cli_file_drop.py @@ -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 diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7585f3336..3909c3ed8 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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"}, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 779881780..20564af65 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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)) diff --git a/ui-tui/README.md b/ui-tui/README.md index 38d206baf..4d7090d5a 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -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. diff --git a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts new file mode 100644 index 000000000..69e6fdbd0 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index e9bf4f5a7..3470e4e08 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -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) }) }) diff --git a/ui-tui/src/__tests__/osc52.test.ts b/ui-tui/src/__tests__/osc52.test.ts new file mode 100644 index 000000000..3d845d5ef --- /dev/null +++ b/ui-tui/src/__tests__/osc52.test.ts @@ -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() + }) +}) diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts new file mode 100644 index 000000000..8465ef0f1 --- /dev/null +++ b/ui-tui/src/__tests__/platform.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/__tests__/terminalParity.test.ts b/ui-tui/src/__tests__/terminalParity.test.ts new file mode 100644 index 000000000..7b822dfc4 --- /dev/null +++ b/ui-tui/src/__tests__/terminalParity.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/__tests__/terminalSetup.test.ts b/ui-tui/src/__tests__/terminalSetup.test.ts new file mode 100644 index 000000000..6ded9177f --- /dev/null +++ b/ui-tui/src/__tests__/terminalSetup.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/__tests__/useComposerState.test.ts b/ui-tui/src/__tests__/useComposerState.test.ts new file mode 100644 index 000000000..0efb7973a --- /dev/null +++ b/ui-tui/src/__tests__/useComposerState.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index da9d0baed..757c59131 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -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 = Promise | T + export interface ComposerActions { clearIn: () => void dequeue: () => string | undefined enqueue: (text: string) => void - handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null + handleTextPaste: (event: PasteEvent) => MaybePromise 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 + 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 input: string inputBuf: string[] pagerPageSize: number diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 0f8916c5c..bde9f9c59 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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', diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 354d3c197..080ed167f 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -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('image.attach', { path: arg, session_id: ctx.sid }).then( ctx.guarded(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) diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 4c47b2b70..38c4ec7c3 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -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([]) const [pasteSnips, setPasteSnips] = useState([]) const isBlocked = useStore($isBlocked) + const { querier } = useStdin() as { querier: Parameters[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): Promise => { 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('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('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 => { + 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(() => { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 77c2681c6..0c4023a62 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) + const terminalHintsShownRef = useRef(new Set()) const historyItemsRef = useRef(historyItems) const lastUserMsgRef = useRef(lastUserMsg) const msgIdsRef = useRef(new WeakMap()) @@ -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) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index f8a40f5a0..7a7969aef 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -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}`) } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 34ae5b798..906c98524 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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 | null | undefined): value is Promise => + !!value && typeof (value as PromiseLike).then === 'function' + export function TextInput({ columns = 80, value, @@ -298,6 +303,7 @@ export function TextInput({ const pasteEnd = useRef(null) const pasteTimer = useRef | 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 diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 3d1bb011b..902b86459 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -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'], diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 34b072f01..73f86c3e0 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -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 diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 64dccc5b4..82ce8b34c 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -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 { - 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 } /** diff --git a/ui-tui/src/lib/osc52.ts b/ui-tui/src/lib/osc52.ts index d99082992..1e6f83fab 100644 --- a/ui-tui/src/lib/osc52.ts +++ b/ui-tui/src/lib/osc52.ts @@ -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 + send: (query: { match: (r: unknown) => r is T; request: string }) => Promise +} + +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 { + if (!querier) { + return null + } + + const response = await querier.send({ + 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`) diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index 8995351a1..eb2e2e10c 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -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 diff --git a/ui-tui/src/lib/terminalParity.ts b/ui-tui/src/lib/terminalParity.ts new file mode 100644 index 000000000..ab62a1884 --- /dev/null +++ b/ui-tui/src/lib/terminalParity.ts @@ -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 { + 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 +} diff --git a/ui-tui/src/lib/terminalSetup.ts b/ui-tui/src/lib/terminalSetup.ts new file mode 100644 index 000000000..0a4d43b10 --- /dev/null +++ b/ui-tui/src/lib/terminalSetup.ts @@ -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 = { + 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 { + 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 + homeDir?: string + now?: () => Date + platform?: NodeJS.Platform + } +): Promise { + 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 + homeDir?: string + platform?: NodeJS.Platform +}): Promise { + 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 + homeDir?: string + platform?: NodeJS.Platform +}): Promise { + 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 + } +} diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 9f8987ad3..507be85a3 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -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 diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 79453474f..bde142820 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -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 ` | 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. diff --git a/website/docs/user-guide/features/vision.md b/website/docs/user-guide/features/vision.md index 8257c186c..0ef77128d 100644 --- a/website/docs/user-guide/features/vision.md +++ b/website/docs/user-guide/features/vision.md @@ -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 diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 9024c690d..72c0a4712 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -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