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: Platform support:
macOS osascript (always available), pngpaste (if installed) macOS osascript (always available), pngpaste (if installed)
Windows PowerShell via .NET System.Windows.Forms.Clipboard Windows PowerShell via WinForms, Get-Clipboard, file-drop fallback
WSL2 powershell.exe via .NET System.Windows.Forms.Clipboard WSL2 powershell.exe via WinForms, Get-Clipboard, file-drop fallback
Linux wl-paste (Wayland), xclip (X11) Linux wl-paste (Wayland), xclip (X11)
""" """
@ -136,6 +136,114 @@ _PS_EXTRACT_IMAGE = (
"[System.Convert]::ToBase64String($ms.ToArray())" "[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 ──────────────────────────────────────────────────────── # ── Native Windows ────────────────────────────────────────────────────────
@ -176,15 +284,7 @@ def _windows_has_image() -> bool:
ps = _get_ps_exe() ps = _get_ps_exe()
if ps is None: if ps is None:
return False return False
try: return _powershell_has_image(ps, timeout=5, label="Windows")
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
def _windows_save(dest: Path) -> bool: def _windows_save(dest: Path) -> bool:
@ -193,26 +293,7 @@ def _windows_save(dest: Path) -> bool:
if ps is None: if ps is None:
logger.debug("No PowerShell found — Windows clipboard image paste unavailable") logger.debug("No PowerShell found — Windows clipboard image paste unavailable")
return False return False
try: return _powershell_save_image(ps, dest, timeout=15, label="Windows")
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
# ── Linux ──────────────────────────────────────────────────────────────── # ── Linux ────────────────────────────────────────────────────────────────
@ -236,45 +317,12 @@ def _linux_save(dest: Path) -> bool:
def _wsl_has_image() -> bool: def _wsl_has_image() -> bool:
"""Check if Windows clipboard has an image (via powershell.exe).""" """Check if Windows clipboard has an image (via powershell.exe)."""
try: return _powershell_has_image("powershell.exe", timeout=8, label="WSL")
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
def _wsl_save(dest: Path) -> bool: def _wsl_save(dest: Path) -> bool:
"""Extract clipboard image via powershell.exe → base64 → decode to PNG.""" """Extract clipboard image via powershell.exe → base64 → decode to PNG."""
try: return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL")
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
# ── Wayland (wl-paste) ────────────────────────────────────────────────── # ── Wayland (wl-paste) ──────────────────────────────────────────────────

View file

@ -250,6 +250,15 @@ class TestWslHasImage:
mock_run.return_value = MagicMock(stdout="False\n", returncode=0) mock_run.return_value = MagicMock(stdout="False\n", returncode=0)
assert _wsl_has_image() is False 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): def test_powershell_not_found(self):
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
assert _wsl_has_image() is False assert _wsl_has_image() is False
@ -269,6 +278,18 @@ class TestWslSave:
assert _wsl_save(dest) is True assert _wsl_save(dest) is True
assert dest.read_bytes() == FAKE_PNG 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): def test_no_image_returns_false(self, tmp_path):
dest = tmp_path / "out.png" dest = tmp_path / "out.png"
with patch("hermes_cli.clipboard.subprocess.run") as mock_run: 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) mock_run.return_value = MagicMock(stdout="False\n", returncode=0)
assert _windows_has_image() is False 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): def test_no_powershell_available(self):
with patch("hermes_cli.clipboard._get_ps_exe", return_value=None): with patch("hermes_cli.clipboard._get_ps_exe", return_value=None):
assert _windows_has_image() is False assert _windows_has_image() is False
@ -559,6 +590,20 @@ class TestWindowsSave:
assert _windows_save(dest) is True assert _windows_save(dest) is True
assert dest.read_bytes() == FAKE_PNG 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): def test_no_image_returns_false(self, tmp_path):
dest = tmp_path / "out.png" dest = tmp_path / "out.png"
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): 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 node.yogaNode = undefined
} }

View file

@ -12,7 +12,7 @@
import { clamp } from './layout/geometry.js' import { clamp } from './layout/geometry.js'
import type { Screen, StylePool } from './screen.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 } 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 { useInputHistory } from './hooks/useInputHistory.js'
import { useQueue } from './hooks/useQueue.js' import { useQueue } from './hooks/useQueue.js'
import { writeOsc52Clipboard } from './lib/osc52.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 { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
import type { import type {
ActiveTool, ActiveTool,
@ -111,7 +119,9 @@ const toTranscriptMessages = (rows: unknown): Msg[] => {
let pendingTools: string[] = [] let pendingTools: string[] = []
for (const row of rows) { for (const row of rows) {
if (!row || typeof row !== 'object') continue if (!row || typeof row !== 'object') {
continue
}
const role = (row as any).role const role = (row as any).role
const text = (row as any).text const text = (row as any).text
@ -120,18 +130,24 @@ const toTranscriptMessages = (rows: unknown): Msg[] => {
const name = (row as any).name ?? 'tool' const name = (row as any).name ?? 'tool'
const ctx = (row as any).context ?? '' const ctx = (row as any).context ?? ''
pendingTools.push(buildToolTrailLine(name, ctx)) pendingTools.push(buildToolTrailLine(name, ctx))
continue continue
} }
if (typeof text !== 'string' || !text.trim()) continue if (typeof text !== 'string' || !text.trim()) {
continue
}
if (role === 'assistant') { if (role === 'assistant') {
const msg: Msg = { role, text } const msg: Msg = { role, text }
if (pendingTools.length) { if (pendingTools.length) {
msg.tools = pendingTools msg.tools = pendingTools
pendingTools = [] pendingTools = []
} }
result.push(msg) result.push(msg)
continue continue
} }
@ -2008,6 +2024,7 @@ export function App({ gw }: { gw: GatewayClient }) {
<ToolTrail <ToolTrail
activity={busy ? activity : []} activity={busy ? activity : []}
animateCot={busy && !streaming} animateCot={busy && !streaming}
padAfter={!!streaming}
t={theme} t={theme}
tools={tools} tools={tools}
trail={turnTrail} trail={turnTrail}

View file

@ -303,9 +303,12 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl
// ── Input handler ──────────────────────────────────────────────── // ── Input handler ────────────────────────────────────────────────
useInput( useInput(
(inp, k) => { (inp, k, event) => {
// Paste hotkey // Some terminals normalize Ctrl+V to "v"; others deliver raw ^V (\x16).
if ((k.ctrl || k.meta) && inp.toLowerCase() === 'v') { 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 }) 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 = [], tools = [],
trail = [], trail = [],
activity = [], activity = [],
animateCot = false animateCot = false,
padAfter = false
}: { }: {
t: Theme t: Theme
tools?: ActiveTool[] tools?: ActiveTool[]
trail?: string[] trail?: string[]
activity?: ActivityItem[] activity?: ActivityItem[]
animateCot?: boolean animateCot?: boolean
padAfter?: boolean
}) { }) {
if (!trail.length && !tools.length && !activity.length) { if (!trail.length && !tools.length && !activity.length) {
return null return null
@ -68,6 +70,7 @@ export const ToolTrail = memo(function ToolTrail({
<> <>
{trail.map((line, i) => { {trail.map((line, i) => {
const lastInBlock = i === rowCount - 1 const lastInBlock = i === rowCount - 1
const suffix = padAfter && lastInBlock ? '\n' : ''
if (isToolTrailResultLine(line)) { if (isToolTrailResultLine(line)) {
return ( return (
@ -78,6 +81,7 @@ export const ToolTrail = memo(function ToolTrail({
> >
<TreeFork last={lastInBlock} /> <TreeFork last={lastInBlock} />
{line} {line}
{suffix}
</Text> </Text>
) )
} }
@ -87,6 +91,7 @@ export const ToolTrail = memo(function ToolTrail({
<Text color={t.color.dim} key={`c-${i}`}> <Text color={t.color.dim} key={`c-${i}`}>
<TreeFork last={lastInBlock} /> <TreeFork last={lastInBlock} />
<Spinner color={t.color.amber} variant="think" /> {line} <Spinner color={t.color.amber} variant="think" /> {line}
{suffix}
</Text> </Text>
) )
} }
@ -95,29 +100,34 @@ export const ToolTrail = memo(function ToolTrail({
<Text color={t.color.dim} dimColor key={`c-${i}`}> <Text color={t.color.dim} dimColor key={`c-${i}`}>
<TreeFork last={lastInBlock} /> <TreeFork last={lastInBlock} />
{line} {line}
{suffix}
</Text> </Text>
) )
})} })}
{tools.map((tool, j) => { {tools.map((tool, j) => {
const lastInBlock = trail.length + j === rowCount - 1 const lastInBlock = trail.length + j === rowCount - 1
const suffix = padAfter && lastInBlock ? '\n' : ''
return ( return (
<Text color={t.color.dim} key={tool.id}> <Text color={t.color.dim} key={tool.id}>
<TreeFork last={lastInBlock} /> <TreeFork last={lastInBlock} />
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name} <Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
{tool.context ? `: ${tool.context}` : ''} {tool.context ? `: ${tool.context}` : ''}
{suffix}
</Text> </Text>
) )
})} })}
{act.map((item, k) => { {act.map((item, k) => {
const lastInBlock = trail.length + tools.length + k === rowCount - 1 const lastInBlock = trail.length + tools.length + k === rowCount - 1
const suffix = padAfter && lastInBlock ? '\n' : ''
return ( return (
<Text color={tone(item, t)} dimColor={item.tone === 'info'} key={`a-${item.id}`}> <Text color={tone(item, t)} dimColor={item.tone === 'info'} key={`a-${item.id}`}>
<TreeFork last={lastInBlock} /> <TreeFork last={lastInBlock} />
{activityGlyph(item)} {item.text} {activityGlyph(item)} {item.text}
{suffix}
</Text> </Text>
) )
})} })}

View file

@ -39,6 +39,7 @@ export const compactPreview = (s: string, max: number) => {
export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => { export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => {
const label = TOOL_VERBS[name] ?? name const label = TOOL_VERBS[name] ?? name
const mark = error ? '✗' : '✓' const mark = error ? '✗' : '✓'
return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}` return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}`
} }