feat: fix img pasting in new ink plus newline after tools

This commit is contained in:
Brooklyn Nicholson 2026-04-11 13:14:32 -05:00
parent b04248f4d5
commit 3fd5cf6e3c
8 changed files with 198 additions and 75 deletions

View file

@ -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) ──────────────────────────────────────────────────

View file

@ -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"):

View file

@ -436,4 +436,3 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
node.yogaNode = undefined
}

View file

@ -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 }

View file

@ -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 }) {
<ToolTrail
activity={busy ? activity : []}
animateCot={busy && !streaming}
padAfter={!!streaming}
t={theme}
tools={tools}
trail={turnTrail}

View file

@ -303,9 +303,12 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl
// ── Input handler ────────────────────────────────────────────────
useInput(
(inp, k) => {
// 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 })
}

View file

@ -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({
>
<TreeFork last={lastInBlock} />
{line}
{suffix}
</Text>
)
}
@ -87,6 +91,7 @@ export const ToolTrail = memo(function ToolTrail({
<Text color={t.color.dim} key={`c-${i}`}>
<TreeFork last={lastInBlock} />
<Spinner color={t.color.amber} variant="think" /> {line}
{suffix}
</Text>
)
}
@ -95,29 +100,34 @@ export const ToolTrail = memo(function ToolTrail({
<Text color={t.color.dim} dimColor key={`c-${i}`}>
<TreeFork last={lastInBlock} />
{line}
{suffix}
</Text>
)
})}
{tools.map((tool, j) => {
const lastInBlock = trail.length + j === rowCount - 1
const suffix = padAfter && lastInBlock ? '\n' : ''
return (
<Text color={t.color.dim} key={tool.id}>
<TreeFork last={lastInBlock} />
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
{tool.context ? `: ${tool.context}` : ''}
{suffix}
</Text>
)
})}
{act.map((item, k) => {
const lastInBlock = trail.length + tools.length + k === rowCount - 1
const suffix = padAfter && lastInBlock ? '\n' : ''
return (
<Text color={tone(item, t)} dimColor={item.tone === 'info'} key={`a-${item.id}`}>
<TreeFork last={lastInBlock} />
{activityGlyph(item)} {item.text}
{suffix}
</Text>
)
})}

View file

@ -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}`
}