From 3fd5cf6e3c3db4e275114c2e08a2d2897dda2ea4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 11 Apr 2026 13:14:32 -0500 Subject: [PATCH] feat: fix img pasting in new ink plus newline after tools --- hermes_cli/clipboard.py | 180 +++++++++++------- tests/tools/test_clipboard.py | 45 +++++ ui-tui/packages/hermes-ink/src/ink/dom.ts | 1 - .../packages/hermes-ink/src/ink/selection.ts | 2 +- ui-tui/src/app.tsx | 23 ++- ui-tui/src/components/textInput.tsx | 9 +- ui-tui/src/components/thinking.tsx | 12 +- ui-tui/src/lib/text.ts | 1 + 8 files changed, 198 insertions(+), 75 deletions(-) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index dfaaf99cd..facc8f3c5 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -7,8 +7,8 @@ CLI tools that ship with the platform (or are commonly installed). Platform support: macOS — osascript (always available), pngpaste (if installed) - Windows — PowerShell via .NET System.Windows.Forms.Clipboard - WSL2 — powershell.exe via .NET System.Windows.Forms.Clipboard + Windows — PowerShell via WinForms, Get-Clipboard, file-drop fallback + WSL2 — powershell.exe via WinForms, Get-Clipboard, file-drop fallback Linux — wl-paste (Wayland), xclip (X11) """ @@ -136,6 +136,114 @@ _PS_EXTRACT_IMAGE = ( "[System.Convert]::ToBase64String($ms.ToArray())" ) +_PS_CHECK_IMAGE_GET_CLIPBOARD = ( + "try { " + "$img = Get-Clipboard -Format Image -ErrorAction Stop;" + "if ($null -ne $img) { 'True' } else { 'False' }" + "} catch { 'False' }" +) + +_PS_EXTRACT_IMAGE_GET_CLIPBOARD = ( + "try { " + "Add-Type -AssemblyName System.Drawing;" + "Add-Type -AssemblyName PresentationCore;" + "Add-Type -AssemblyName WindowsBase;" + "$img = Get-Clipboard -Format Image -ErrorAction Stop;" + "if ($null -eq $img) { exit 1 }" + "$ms = New-Object System.IO.MemoryStream;" + "if ($img -is [System.Drawing.Image]) {" + "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)" + "} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {" + "$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;" + "$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));" + "$enc.Save($ms)" + "} else { exit 2 }" + "[System.Convert]::ToBase64String($ms.ToArray())" + "} catch { exit 1 }" +) + +_FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'" + +_PS_CHECK_FILEDROP_IMAGE = ( + "try { " + "$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;" + f"$exts = @({_FILEDROP_IMAGE_EXTS});" + "$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;" + "if ($null -ne $hit) { 'True' } else { 'False' }" + "} catch { 'False' }" +) + +_PS_EXTRACT_FILEDROP_IMAGE = ( + "try { " + "$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;" + f"$exts = @({_FILEDROP_IMAGE_EXTS});" + "$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;" + "if ($null -eq $hit) { exit 1 }" + "[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))" + "} catch { exit 1 }" +) + +_POWERSHELL_HAS_IMAGE_SCRIPTS = ( + _PS_CHECK_IMAGE, + _PS_CHECK_IMAGE_GET_CLIPBOARD, + _PS_CHECK_FILEDROP_IMAGE, +) + +_POWERSHELL_EXTRACT_IMAGE_SCRIPTS = ( + _PS_EXTRACT_IMAGE, + _PS_EXTRACT_IMAGE_GET_CLIPBOARD, + _PS_EXTRACT_FILEDROP_IMAGE, +) + + +def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess: + return subprocess.run( + [exe, "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, timeout=timeout, + ) + + +def _write_base64_image(dest: Path, b64_data: str) -> bool: + image_bytes = base64.b64decode(b64_data, validate=True) + dest.write_bytes(image_bytes) + return dest.exists() and dest.stat().st_size > 0 + + +def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool: + for script in _POWERSHELL_HAS_IMAGE_SCRIPTS: + try: + r = _run_powershell(exe, script, timeout=timeout) + if r.returncode == 0 and "True" in r.stdout: + return True + except FileNotFoundError: + logger.debug("%s not found — clipboard unavailable", exe) + return False + except Exception as e: + logger.debug("%s clipboard image check failed: %s", label, e) + return False + + +def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> bool: + for script in _POWERSHELL_EXTRACT_IMAGE_SCRIPTS: + try: + r = _run_powershell(exe, script, timeout=timeout) + if r.returncode != 0: + continue + + b64_data = r.stdout.strip() + if not b64_data: + continue + + if _write_base64_image(dest, b64_data): + return True + except FileNotFoundError: + logger.debug("%s not found — clipboard unavailable", exe) + return False + except Exception as e: + logger.debug("%s clipboard image extraction failed: %s", label, e) + dest.unlink(missing_ok=True) + return False + # ── Native Windows ──────────────────────────────────────────────────────── @@ -176,15 +284,7 @@ def _windows_has_image() -> bool: ps = _get_ps_exe() if ps is None: return False - try: - r = subprocess.run( - [ps, "-NoProfile", "-NonInteractive", "-Command", _PS_CHECK_IMAGE], - capture_output=True, text=True, timeout=5, - ) - return r.returncode == 0 and "True" in r.stdout - except Exception as e: - logger.debug("Windows clipboard image check failed: %s", e) - return False + return _powershell_has_image(ps, timeout=5, label="Windows") def _windows_save(dest: Path) -> bool: @@ -193,26 +293,7 @@ def _windows_save(dest: Path) -> bool: if ps is None: logger.debug("No PowerShell found — Windows clipboard image paste unavailable") return False - try: - r = subprocess.run( - [ps, "-NoProfile", "-NonInteractive", "-Command", _PS_EXTRACT_IMAGE], - capture_output=True, text=True, timeout=15, - ) - if r.returncode != 0: - return False - - b64_data = r.stdout.strip() - if not b64_data: - return False - - png_bytes = base64.b64decode(b64_data) - dest.write_bytes(png_bytes) - return dest.exists() and dest.stat().st_size > 0 - - except Exception as e: - logger.debug("Windows clipboard image extraction failed: %s", e) - dest.unlink(missing_ok=True) - return False + return _powershell_save_image(ps, dest, timeout=15, label="Windows") # ── Linux ──────────────────────────────────────────────────────────────── @@ -236,45 +317,12 @@ def _linux_save(dest: Path) -> bool: def _wsl_has_image() -> bool: """Check if Windows clipboard has an image (via powershell.exe).""" - try: - r = subprocess.run( - ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", - _PS_CHECK_IMAGE], - capture_output=True, text=True, timeout=8, - ) - return r.returncode == 0 and "True" in r.stdout - except FileNotFoundError: - logger.debug("powershell.exe not found — WSL clipboard unavailable") - except Exception as e: - logger.debug("WSL clipboard check failed: %s", e) - return False + return _powershell_has_image("powershell.exe", timeout=8, label="WSL") def _wsl_save(dest: Path) -> bool: """Extract clipboard image via powershell.exe → base64 → decode to PNG.""" - try: - r = subprocess.run( - ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", - _PS_EXTRACT_IMAGE], - capture_output=True, text=True, timeout=15, - ) - if r.returncode != 0: - return False - - b64_data = r.stdout.strip() - if not b64_data: - return False - - png_bytes = base64.b64decode(b64_data) - dest.write_bytes(png_bytes) - return dest.exists() and dest.stat().st_size > 0 - - except FileNotFoundError: - logger.debug("powershell.exe not found — WSL clipboard unavailable") - except Exception as e: - logger.debug("WSL clipboard extraction failed: %s", e) - dest.unlink(missing_ok=True) - return False + return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL") # ── Wayland (wl-paste) ────────────────────────────────────────────────── diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index a491edfaa..17f929eb9 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -250,6 +250,15 @@ class TestWslHasImage: mock_run.return_value = MagicMock(stdout="False\n", returncode=0) assert _wsl_has_image() is False + def test_falls_back_to_get_clipboard_image(self): + with patch("hermes_cli.clipboard.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(stdout="False\n", returncode=0), + MagicMock(stdout="True\n", returncode=0), + ] + assert _wsl_has_image() is True + assert mock_run.call_count == 2 + def test_powershell_not_found(self): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _wsl_has_image() is False @@ -269,6 +278,18 @@ class TestWslSave: assert _wsl_save(dest) is True assert dest.read_bytes() == FAKE_PNG + def test_falls_back_to_get_clipboard_extraction(self, tmp_path): + dest = tmp_path / "out.png" + b64_png = base64.b64encode(FAKE_PNG).decode() + with patch("hermes_cli.clipboard.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(stdout="", returncode=1), + MagicMock(stdout=b64_png + "\n", returncode=0), + ] + assert _wsl_save(dest) is True + assert mock_run.call_count == 2 + assert dest.read_bytes() == FAKE_PNG + def test_no_image_returns_false(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: @@ -528,6 +549,16 @@ class TestWindowsHasImage: mock_run.return_value = MagicMock(stdout="False\n", returncode=0) assert _windows_has_image() is False + def test_falls_back_to_get_clipboard_image(self): + with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): + with patch("hermes_cli.clipboard.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(stdout="False\n", returncode=0), + MagicMock(stdout="True\n", returncode=0), + ] + assert _windows_has_image() is True + assert mock_run.call_count == 2 + def test_no_powershell_available(self): with patch("hermes_cli.clipboard._get_ps_exe", return_value=None): assert _windows_has_image() is False @@ -559,6 +590,20 @@ class TestWindowsSave: assert _windows_save(dest) is True assert dest.read_bytes() == FAKE_PNG + def test_falls_back_to_filedrop_image(self, tmp_path): + dest = tmp_path / "out.png" + b64_png = base64.b64encode(FAKE_PNG).decode() + with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): + with patch("hermes_cli.clipboard.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(stdout="", returncode=1), + MagicMock(stdout="", returncode=1), + MagicMock(stdout=b64_png + "\n", returncode=0), + ] + assert _windows_save(dest) is True + assert mock_run.call_count == 3 + assert dest.read_bytes() == FAKE_PNG + def test_no_image_returns_false(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 20b72968a..6c4b19830 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -436,4 +436,3 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { node.yogaNode = undefined } - diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts index 03a32b823..9ee71564e 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -12,7 +12,7 @@ import { clamp } from './layout/geometry.js' import type { Screen, StylePool } from './screen.js' -import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js' +import { cellAt, cellAtIndex, CellWidth, setCellStyleId } from './screen.js' type Point = { col: number; row: number } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 5065c37d6..d7b6e4ae2 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -20,7 +20,15 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { buildToolTrailLine, compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' +import { + buildToolTrailLine, + compactPreview, + fmtK, + hasInterpolation, + isToolTrailResultLine, + pick, + sameToolTrailGroup +} from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -111,7 +119,9 @@ const toTranscriptMessages = (rows: unknown): Msg[] => { let pendingTools: string[] = [] for (const row of rows) { - if (!row || typeof row !== 'object') continue + if (!row || typeof row !== 'object') { + continue + } const role = (row as any).role const text = (row as any).text @@ -120,18 +130,24 @@ const toTranscriptMessages = (rows: unknown): Msg[] => { const name = (row as any).name ?? 'tool' const ctx = (row as any).context ?? '' pendingTools.push(buildToolTrailLine(name, ctx)) + continue } - if (typeof text !== 'string' || !text.trim()) continue + if (typeof text !== 'string' || !text.trim()) { + continue + } if (role === 'assistant') { const msg: Msg = { role, text } + if (pendingTools.length) { msg.tools = pendingTools pendingTools = [] } + result.push(msg) + continue } @@ -2008,6 +2024,7 @@ export function App({ gw }: { gw: GatewayClient }) { { - // Paste hotkey - if ((k.ctrl || k.meta) && inp.toLowerCase() === 'v') { + (inp, k, event) => { + // Some terminals normalize Ctrl+V to "v"; others deliver raw ^V (\x16). + const ctrlPaste = k.ctrl && (inp.toLowerCase() === 'v' || event.keypress.raw === '\x16') + const metaPaste = k.meta && inp.toLowerCase() === 'v' + + if (ctrlPaste || metaPaste) { return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index b2b8c3d59..bcee8b7a7 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -48,13 +48,15 @@ export const ToolTrail = memo(function ToolTrail({ tools = [], trail = [], activity = [], - animateCot = false + animateCot = false, + padAfter = false }: { t: Theme tools?: ActiveTool[] trail?: string[] activity?: ActivityItem[] animateCot?: boolean + padAfter?: boolean }) { if (!trail.length && !tools.length && !activity.length) { return null @@ -68,6 +70,7 @@ export const ToolTrail = memo(function ToolTrail({ <> {trail.map((line, i) => { const lastInBlock = i === rowCount - 1 + const suffix = padAfter && lastInBlock ? '\n' : '' if (isToolTrailResultLine(line)) { return ( @@ -78,6 +81,7 @@ export const ToolTrail = memo(function ToolTrail({ > {line} + {suffix} ) } @@ -87,6 +91,7 @@ export const ToolTrail = memo(function ToolTrail({ {line} + {suffix} ) } @@ -95,29 +100,34 @@ export const ToolTrail = memo(function ToolTrail({ {line} + {suffix} ) })} {tools.map((tool, j) => { const lastInBlock = trail.length + j === rowCount - 1 + const suffix = padAfter && lastInBlock ? '\n' : '' return ( {TOOL_VERBS[tool.name] ?? tool.name} {tool.context ? `: ${tool.context}` : ''} + {suffix} ) })} {act.map((item, k) => { const lastInBlock = trail.length + tools.length + k === rowCount - 1 + const suffix = padAfter && lastInBlock ? '\n' : '' return ( {activityGlyph(item)} {item.text} + {suffix} ) })} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 88418d280..fb4294318 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -39,6 +39,7 @@ export const compactPreview = (s: string, max: number) => { export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => { const label = TOOL_VERBS[name] ?? name const mark = error ? '✗' : '✓' + return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}` }