mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
432772dbdf
commit
9556fef5a1
31 changed files with 1303 additions and 100 deletions
32
cli.py
32
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
66
ui-tui/src/__tests__/osc52.test.ts
Normal file
66
ui-tui/src/__tests__/osc52.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
31
ui-tui/src/__tests__/platform.test.ts
Normal file
31
ui-tui/src/__tests__/platform.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
21
ui-tui/src/__tests__/terminalParity.test.ts
Normal file
21
ui-tui/src/__tests__/terminalParity.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
169
ui-tui/src/__tests__/terminalSetup.test.ts
Normal file
169
ui-tui/src/__tests__/terminalSetup.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
15
ui-tui/src/__tests__/useComposerState.test.ts
Normal file
15
ui-tui/src/__tests__/useComposerState.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
|
|||
|
||||
import type { PasteEvent } from '../components/textInput.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { ImageAttachResponse } from '../gatewayTypes.js'
|
||||
import type { RpcResult } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type {
|
||||
|
|
@ -106,11 +107,13 @@ export interface ComposerPasteResult {
|
|||
value: string
|
||||
}
|
||||
|
||||
export type MaybePromise<T> = Promise<T> | T
|
||||
|
||||
export interface ComposerActions {
|
||||
clearIn: () => void
|
||||
dequeue: () => string | undefined
|
||||
enqueue: (text: string) => void
|
||||
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
|
||||
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
||||
openEditor: () => void
|
||||
pushHistory: (text: string) => void
|
||||
replaceQueue: (index: number, text: string) => void
|
||||
|
|
@ -146,6 +149,7 @@ export interface ComposerState {
|
|||
export interface UseComposerStateOptions {
|
||||
gw: GatewayClient
|
||||
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
|
||||
onImageAttached?: (info: ImageAttachResponse) => void
|
||||
submitRef: MutableRefObject<(value: string) => void>
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +272,7 @@ export interface AppLayoutComposerProps {
|
|||
compIdx: number
|
||||
completions: CompletionItem[]
|
||||
empty: boolean
|
||||
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
|
||||
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
||||
input: string
|
||||
inputBuf: string[]
|
||||
pagerPageSize: number
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { imageTokenMeta, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
|
||||
import { introMsg, toTranscriptMessages, attachedImageNotice } from '../../../domain/messages.js'
|
||||
import type {
|
||||
BackgroundStartResponse,
|
||||
BtwStartResponse,
|
||||
|
|
@ -92,9 +92,7 @@ export const sessionCommands: SlashCommand[] = [
|
|||
run: (arg, ctx) => {
|
||||
ctx.gateway.rpc<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then(
|
||||
ctx.guarded<ImageAttachResponse>(r => {
|
||||
const meta = imageTokenMeta(r)
|
||||
|
||||
ctx.transcript.sys(`attached image: ${r.name ?? ''}${meta ? ` · ${meta}` : ''}`)
|
||||
ctx.transcript.sys(attachedImageNotice(r))
|
||||
|
||||
if (r.remainder) {
|
||||
ctx.composer.setInput(r.remainder)
|
||||
|
|
|
|||
|
|
@ -5,16 +5,21 @@ import { join } from 'node:path'
|
|||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useStdin } from '@hermes/ink'
|
||||
|
||||
import type { PasteEvent } from '../components/textInput.js'
|
||||
import { LARGE_PASTE } from '../config/limits.js'
|
||||
import { useCompletion } from '../hooks/useCompletion.js'
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js'
|
||||
import { useQueue } from '../hooks/useQueue.js'
|
||||
import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
|
||||
import { readOsc52Clipboard } from '../lib/osc52.js'
|
||||
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
|
||||
import type { InputDetectDropResponse } from '../gatewayTypes.js'
|
||||
|
||||
import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
|
||||
import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
|
||||
import { $isBlocked } from './overlayStore.js'
|
||||
import { getUiState } from './uiStore.js'
|
||||
|
||||
const PASTE_SNIP_MAX_COUNT = 32
|
||||
const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024
|
||||
|
|
@ -38,11 +43,34 @@ const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => {
|
|||
return out.length === snips.length ? snips : out
|
||||
}
|
||||
|
||||
export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult {
|
||||
export function looksLikeDroppedPath(text: string): boolean {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (!trimmed || trimmed.includes('\n')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
trimmed.startsWith('/') ||
|
||||
trimmed.startsWith('~') ||
|
||||
trimmed.startsWith('./') ||
|
||||
trimmed.startsWith('../') ||
|
||||
trimmed.startsWith('file://') ||
|
||||
trimmed.startsWith('"/') ||
|
||||
trimmed.startsWith("'/") ||
|
||||
trimmed.startsWith('"~') ||
|
||||
trimmed.startsWith("'~") ||
|
||||
(/^[A-Za-z]:[\\/]/.test(trimmed)) ||
|
||||
(/^["'][A-Za-z]:[\\/]/.test(trimmed))
|
||||
)
|
||||
}
|
||||
|
||||
export function useComposerState({ gw, onClipboardPaste, onImageAttached, submitRef }: UseComposerStateOptions): UseComposerStateResult {
|
||||
const [input, setInput] = useState('')
|
||||
const [inputBuf, setInputBuf] = useState<string[]>([])
|
||||
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
|
||||
const isBlocked = useStore($isBlocked)
|
||||
const { querier } = useStdin() as { querier: Parameters<typeof readOsc52Clipboard>[0] }
|
||||
|
||||
const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } =
|
||||
useQueue()
|
||||
|
|
@ -59,14 +87,8 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
|
|||
historyDraftRef.current = ''
|
||||
}, [historyDraftRef, setQueueEdit, setHistoryIdx])
|
||||
|
||||
const handleTextPaste = useCallback(
|
||||
({ bracketed, cursor, hotkey, text, value }: PasteEvent) => {
|
||||
if (hotkey) {
|
||||
void onClipboardPaste(false)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleResolvedPaste = useCallback(
|
||||
async ({ bracketed, cursor, text, value }: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => {
|
||||
const cleanedText = stripTrailingPasteNewlines(text)
|
||||
|
||||
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
|
||||
|
|
@ -77,6 +99,55 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
|
|||
return null
|
||||
}
|
||||
|
||||
const sid = getUiState().sid
|
||||
if (sid && looksLikeDroppedPath(cleanedText)) {
|
||||
try {
|
||||
const attached = await gw.request<InputDetectDropResponse & { remainder?: string }>('image.attach', {
|
||||
path: cleanedText,
|
||||
session_id: sid
|
||||
})
|
||||
|
||||
if (attached?.name) {
|
||||
onImageAttached?.(attached)
|
||||
const remainder = attached.remainder?.trim() ?? ''
|
||||
if (!remainder) {
|
||||
return { cursor, value }
|
||||
}
|
||||
|
||||
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
|
||||
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
|
||||
const insert = `${lead}${remainder}${tail}`
|
||||
|
||||
return {
|
||||
cursor: cursor + insert.length,
|
||||
value: value.slice(0, cursor) + insert + value.slice(cursor)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to generic file-drop detection below.
|
||||
}
|
||||
|
||||
try {
|
||||
const dropped = await gw.request<InputDetectDropResponse>('input.detect_drop', {
|
||||
session_id: sid,
|
||||
text: cleanedText
|
||||
})
|
||||
|
||||
if (dropped?.matched && dropped.text) {
|
||||
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
|
||||
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
|
||||
const insert = `${lead}${dropped.text}${tail}`
|
||||
|
||||
return {
|
||||
cursor: cursor + insert.length,
|
||||
value: value.slice(0, cursor) + insert + value.slice(cursor)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to normal text paste behavior.
|
||||
}
|
||||
}
|
||||
|
||||
const lineCount = cleanedText.split('\n').length
|
||||
|
||||
if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) {
|
||||
|
|
@ -111,7 +182,40 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
|
|||
value: value.slice(0, cursor) + insert + value.slice(cursor)
|
||||
}
|
||||
},
|
||||
[gw, onClipboardPaste]
|
||||
[gw, onClipboardPaste, onImageAttached]
|
||||
)
|
||||
|
||||
const handleTextPaste = useCallback(
|
||||
({ bracketed, cursor, hotkey, text, value }: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => {
|
||||
if (hotkey) {
|
||||
const preferOsc52 = Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT)
|
||||
const readPreferredText = preferOsc52
|
||||
? readOsc52Clipboard(querier).then(async osc52Text => {
|
||||
if (isUsableClipboardText(osc52Text)) {
|
||||
return osc52Text
|
||||
}
|
||||
return readClipboardText()
|
||||
})
|
||||
: readClipboardText().then(async clipText => {
|
||||
if (isUsableClipboardText(clipText)) {
|
||||
return clipText
|
||||
}
|
||||
return readOsc52Clipboard(querier)
|
||||
})
|
||||
|
||||
return readPreferredText.then(async preferredText => {
|
||||
if (isUsableClipboardText(preferredText)) {
|
||||
return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value })
|
||||
}
|
||||
|
||||
void onClipboardPaste(false)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
return handleResolvedPaste({ bracketed: !!bracketed, cursor, text, value })
|
||||
},
|
||||
[gw, handleResolvedPaste, onClipboardPaste, querier]
|
||||
)
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { STARTUP_RESUME_ID } from '../config/env.js'
|
||||
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
||||
import { imageTokenMeta } from '../domain/messages.js'
|
||||
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
import { fmtCwdBranch } from '../domain/paths.js'
|
||||
import { type GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
|
|
@ -117,6 +118,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
|
||||
const clipboardPasteRef = useRef<(quiet?: boolean) => Promise<void> | void>(() => {})
|
||||
const submitRef = useRef<(value: string) => void>(() => {})
|
||||
const terminalHintsShownRef = useRef(new Set<string>())
|
||||
const historyItemsRef = useRef(historyItems)
|
||||
const lastUserMsgRef = useRef(lastUserMsg)
|
||||
const msgIdsRef = useRef(new WeakMap<Msg, string>())
|
||||
|
|
@ -136,12 +138,30 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const composer = useComposerState({
|
||||
gw,
|
||||
onClipboardPaste: quiet => clipboardPasteRef.current(quiet),
|
||||
onImageAttached: info => {
|
||||
sys(attachedImageNotice(info))
|
||||
},
|
||||
submitRef
|
||||
})
|
||||
|
||||
const { actions: composerActions, refs: composerRefs, state: composerState } = composer
|
||||
const empty = !historyItems.some(msg => msg.kind !== 'intro')
|
||||
|
||||
useEffect(() => {
|
||||
void terminalParityHints()
|
||||
.then(hints => {
|
||||
for (const hint of hints) {
|
||||
if (terminalHintsShownRef.current.has(hint.key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
terminalHintsShownRef.current.add(hint.key)
|
||||
turnController.pushActivity(hint.message, hint.tone)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const messageId = useCallback((msg: Msg) => {
|
||||
const hit = msgIdsRef.current.get(msg)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { setInputSelection } from '../app/inputSelectionStore.js'
|
||||
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
|
||||
import { isActionMod, isMac } from '../lib/platform.js'
|
||||
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
stringWidth: (s: string) => number
|
||||
|
|
@ -275,6 +275,11 @@ function useFwdDelete(active: boolean) {
|
|||
return ref
|
||||
}
|
||||
|
||||
type PasteResult = { cursor: number; value: string } | null
|
||||
|
||||
const isPasteResultPromise = (value: PasteResult | Promise<PasteResult> | null | undefined): value is Promise<PasteResult> =>
|
||||
!!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
|
||||
|
||||
export function TextInput({
|
||||
columns = 80,
|
||||
value,
|
||||
|
|
@ -298,6 +303,7 @@ export function TextInput({
|
|||
const pasteEnd = useRef<null | number>(null)
|
||||
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pastePos = useRef(0)
|
||||
const editVersionRef = useRef(0)
|
||||
const undo = useRef<{ cursor: number; value: string }[]>([])
|
||||
const redo = useRef<{ cursor: number; value: string }[]>([])
|
||||
|
||||
|
|
@ -389,6 +395,7 @@ export function TextInput({
|
|||
const commit = (next: string, nextCur: number, track = true) => {
|
||||
const prev = vRef.current
|
||||
const c = snapPos(next, nextCur)
|
||||
editVersionRef.current += 1
|
||||
|
||||
if (selRef.current) {
|
||||
selRef.current = null
|
||||
|
|
@ -427,8 +434,21 @@ export function TextInput({
|
|||
}
|
||||
|
||||
const emitPaste = (e: PasteEvent) => {
|
||||
const startVersion = editVersionRef.current
|
||||
const h = cbPaste.current?.(e)
|
||||
|
||||
if (isPasteResultPromise(h)) {
|
||||
void h
|
||||
.then(result => {
|
||||
if (result && editVersionRef.current === startVersion) {
|
||||
commit(result.value, result.cursor)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (h) {
|
||||
commit(h.value, h.cursor)
|
||||
}
|
||||
|
|
@ -506,7 +526,12 @@ export function TextInput({
|
|||
(inp: string, k: Key, event: InputEvent) => {
|
||||
const eventRaw = event.keypress.raw
|
||||
|
||||
if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16' || (isMac && k.meta && inp.toLowerCase() === 'v')) {
|
||||
if (
|
||||
eventRaw === '\x1bv' ||
|
||||
eventRaw === '\x1bV' ||
|
||||
eventRaw === '\x16' ||
|
||||
(isMac && isActionMod(k) && inp.toLowerCase() === 'v')
|
||||
) {
|
||||
if (cbPaste.current) {
|
||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||
}
|
||||
|
|
@ -522,7 +547,7 @@ export function TextInput({
|
|||
return
|
||||
}
|
||||
|
||||
if (isMac && k.meta && inp.toLowerCase() === 'c') {
|
||||
if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') {
|
||||
const range = selRange()
|
||||
|
||||
if (range) {
|
||||
|
|
@ -548,7 +573,7 @@ export function TextInput({
|
|||
}
|
||||
|
||||
if (k.return) {
|
||||
k.shift || k.meta
|
||||
k.shift || (isMac && isActionMod(k))
|
||||
? commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
|
||||
: cbSubmit.current?.(vRef.current)
|
||||
|
||||
|
|
@ -558,6 +583,9 @@ export function TextInput({
|
|||
let c = curRef.current
|
||||
let v = vRef.current
|
||||
const mod = isActionMod(k)
|
||||
const actionHome = k.home || isMacActionFallback(k, inp, 'a')
|
||||
const actionEnd = k.end || (mod && inp === 'e') || isMacActionFallback(k, inp, 'e')
|
||||
const actionDeleteToStart = (mod && inp === 'u') || isMacActionFallback(k, inp, 'u')
|
||||
const range = selRange()
|
||||
const delFwd = k.delete || fwdDel.current
|
||||
|
||||
|
|
@ -573,10 +601,10 @@ export function TextInput({
|
|||
return selectAll()
|
||||
}
|
||||
|
||||
if (k.home) {
|
||||
if (actionHome) {
|
||||
clearSel()
|
||||
c = 0
|
||||
} else if (k.end || (mod && inp === 'e')) {
|
||||
} else if (actionEnd) {
|
||||
clearSel()
|
||||
c = v.length
|
||||
} else if (k.leftArrow) {
|
||||
|
|
@ -633,7 +661,7 @@ export function TextInput({
|
|||
} else {
|
||||
return
|
||||
}
|
||||
} else if (mod && inp === 'u') {
|
||||
} else if (actionDeleteToStart) {
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
|
|
@ -742,7 +770,7 @@ interface TextInputProps {
|
|||
focus?: boolean
|
||||
mask?: string
|
||||
onChange: (v: string) => void
|
||||
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
|
||||
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
|
||||
onSubmit?: (v: string) => void
|
||||
placeholder?: string
|
||||
value: string
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,29 +2,88 @@ import { execFile, spawn } from 'node:child_process'
|
|||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const CLIPBOARD_MAX_BUFFER = 4 * 1024 * 1024
|
||||
const POWERSHELL_ARGS = ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'] as const
|
||||
|
||||
type ClipboardRun = typeof execFileAsync
|
||||
|
||||
export function isUsableClipboardText(text: null | string): text is string {
|
||||
if (!text || !/[^\s]/.test(text)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (text.includes('\u0000')) {
|
||||
return false
|
||||
}
|
||||
|
||||
let suspicious = 0
|
||||
for (const ch of text) {
|
||||
const code = ch.charCodeAt(0)
|
||||
const isControl = code < 0x20 && ch !== '\n' && ch !== '\r' && ch !== '\t'
|
||||
if (isControl || ch === '\ufffd') {
|
||||
suspicious += 1
|
||||
}
|
||||
}
|
||||
|
||||
return suspicious <= Math.max(2, Math.floor(text.length * 0.02))
|
||||
}
|
||||
|
||||
function readClipboardCommands(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Array<{ args: readonly string[]; cmd: string }> {
|
||||
if (platform === 'darwin') {
|
||||
return [{ cmd: 'pbpaste', args: [] }]
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return [{ cmd: 'powershell', args: POWERSHELL_ARGS }]
|
||||
}
|
||||
|
||||
const attempts: Array<{ args: readonly string[]; cmd: string }> = []
|
||||
|
||||
if (env.WSL_INTEROP) {
|
||||
attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS })
|
||||
}
|
||||
|
||||
if (env.WAYLAND_DISPLAY) {
|
||||
attempts.push({ cmd: 'wl-paste', args: ['--type', 'text'] })
|
||||
}
|
||||
|
||||
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-out'] })
|
||||
|
||||
return attempts
|
||||
}
|
||||
|
||||
/**
|
||||
* Read plain text from the system clipboard.
|
||||
*
|
||||
* On macOS this uses `pbpaste`. On other platforms we intentionally return
|
||||
* null for now; the TUI's text-paste hotkeys are primarily targeted at the
|
||||
* macOS clarify/input flow.
|
||||
* Uses native platform tools in fallback order:
|
||||
* - macOS: pbpaste
|
||||
* - Windows: PowerShell Get-Clipboard -Raw
|
||||
* - WSL: powershell.exe Get-Clipboard -Raw
|
||||
* - Linux Wayland: wl-paste --type text
|
||||
* - Linux X11: xclip -selection clipboard -out
|
||||
*/
|
||||
export async function readClipboardText(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
run: typeof execFileAsync = execFileAsync
|
||||
run: ClipboardRun = execFileAsync,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<string | null> {
|
||||
if (platform !== 'darwin') {
|
||||
return null
|
||||
for (const attempt of readClipboardCommands(platform, env)) {
|
||||
try {
|
||||
const result = await run(attempt.cmd, [...attempt.args], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: CLIPBOARD_MAX_BUFFER,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
if (typeof result.stdout === 'string') {
|
||||
return result.stdout
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the next clipboard backend.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run('pbpaste', [], { encoding: 'utf8', windowsHide: true })
|
||||
|
||||
return typeof result.stdout === 'string' ? result.stdout : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,2 +1,69 @@
|
|||
const ESC = '\x1b'
|
||||
const BEL = '\x07'
|
||||
const ST = `${ESC}\\`
|
||||
|
||||
export const OSC52_CLIPBOARD_QUERY = `${ESC}]52;c;?${BEL}`
|
||||
|
||||
type OscResponse = { code: number; data: string; type: 'osc' }
|
||||
|
||||
type OscQuerier = {
|
||||
flush: () => Promise<void>
|
||||
send: <T>(query: { match: (r: unknown) => r is T; request: string }) => Promise<T | undefined>
|
||||
}
|
||||
|
||||
function wrapForMultiplexer(sequence: string): string {
|
||||
if (process.env['TMUX']) {
|
||||
return `${ESC}Ptmux;${sequence.split(ESC).join(ESC + ESC)}${ST}`
|
||||
}
|
||||
|
||||
if (process.env['STY']) {
|
||||
return `${ESC}P${sequence}${ST}`
|
||||
}
|
||||
|
||||
return sequence
|
||||
}
|
||||
|
||||
export function buildOsc52ClipboardQuery(): string {
|
||||
return wrapForMultiplexer(OSC52_CLIPBOARD_QUERY)
|
||||
}
|
||||
|
||||
export function parseOsc52ClipboardData(data: string): null | string {
|
||||
const firstSep = data.indexOf(';')
|
||||
|
||||
if (firstSep === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selection = data.slice(0, firstSep)
|
||||
const payload = data.slice(firstSep + 1)
|
||||
|
||||
if ((selection !== 'c' && selection !== 'p') || !payload || payload === '?') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return Buffer.from(payload, 'base64').toString('utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function readOsc52Clipboard(querier: null | OscQuerier): Promise<null | string> {
|
||||
if (!querier) {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await querier.send<OscResponse>({
|
||||
request: buildOsc52ClipboardQuery(),
|
||||
match: (r: unknown): r is OscResponse => {
|
||||
return !!r && typeof r === 'object' && (r as OscResponse).type === 'osc' && (r as OscResponse).code === 52
|
||||
}
|
||||
})
|
||||
|
||||
await querier.flush()
|
||||
|
||||
return response ? parseOsc52ClipboardData(response.data) : null
|
||||
}
|
||||
|
||||
export const writeOsc52Clipboard = (s: string) =>
|
||||
process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
ui-tui/src/lib/terminalParity.ts
Normal file
64
ui-tui/src/lib/terminalParity.ts
Normal 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
|
||||
}
|
||||
278
ui-tui/src/lib/terminalSetup.ts
Normal file
278
ui-tui/src/lib/terminalSetup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
1
ui-tui/src/types/hermes-ink.d.ts
vendored
1
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,9 +78,10 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
|||
| `/usage` | Show token usage, cost breakdown, and session duration |
|
||||
| `/insights` | Show usage insights and analytics (last 30 days) |
|
||||
| `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status |
|
||||
| `/paste` | Check clipboard for an image and attach it |
|
||||
| `/paste` | Attach a clipboard image |
|
||||
| `/copy [number]` | Copy the last assistant response to clipboard (or the Nth-from-last with a number). CLI-only. |
|
||||
| `/image <path>` | Attach a local image file for your next prompt. |
|
||||
| `/terminal-setup [auto\|vscode\|cursor\|windsurf]` | TUI-only: configure local VS Code-family terminal bindings for better multiline + undo/redo parity. |
|
||||
| `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. |
|
||||
| `/profile` | Show active profile name and home directory |
|
||||
| `/gquota` | Show Google Gemini Code Assist quota usage with progress bars (only available when the `google-gemini-cli` provider is active). |
|
||||
|
|
@ -157,7 +158,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||
|
||||
## Notes
|
||||
|
||||
- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, and `/plugins` are **CLI-only** commands.
|
||||
- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/terminal-setup`, `/statusbar`, and `/plugins` are **CLI-only** commands.
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, and `/commands` are **messaging-only** commands.
|
||||
- `/status`, `/background`, `/voice`, `/reload-mcp`, `/rollback`, `/snapshot`, `/debug`, `/fast`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue