mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: fix img pasting in new ink plus newline after tools
This commit is contained in:
parent
b04248f4d5
commit
3fd5cf6e3c
8 changed files with 198 additions and 75 deletions
|
|
@ -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) ──────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -436,4 +436,3 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
|
||||||
|
|
||||||
node.yogaNode = undefined
|
node.yogaNode = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue