Compare commits

..

2 Commits

Author SHA1 Message Date
db51abb970 Add image paste support for xterm.js terminal
All checks were successful
Build App / build-linux (push) Successful in 2m41s
Build App / build-windows (push) Successful in 3m56s
Intercept clipboard paste events containing images in the terminal,
upload them into the Docker container via bollard's tar upload API,
and inject the resulting file path into terminal stdin so Claude Code
can reference the image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:52:08 -08:00
d947824436 Fix URL detector truncating wrapped URLs by flattening buffer
All checks were successful
Build App / build-linux (push) Successful in 2m32s
Build App / build-windows (push) Successful in 3m45s
Replace fragile line-by-line reassembly heuristic with a simpler
approach: flatten the buffer by converting blank lines to spaces
(URL terminators) and stripping remaining newlines (PTY wraps),
then match URLs with a single regex on the flat string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:15:29 -08:00
7 changed files with 157 additions and 32 deletions

View File

@@ -72,3 +72,23 @@ pub async fn close_terminal_session(
state.exec_manager.close_session(&session_id).await; state.exec_manager.close_session(&session_id).await;
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn paste_image_to_terminal(
session_id: String,
image_data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let file_name = format!("clipboard_{}.png", timestamp);
state
.exec_manager
.write_file_to_container(&container_id, &file_name, &image_data)
.await
}

View File

@@ -1,3 +1,4 @@
use bollard::container::UploadToContainerOptions;
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults}; use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures_util::StreamExt; use futures_util::StreamExt;
use std::collections::HashMap; use std::collections::HashMap;
@@ -212,4 +213,51 @@ impl ExecSessionManager {
session.shutdown(); session.shutdown();
} }
} }
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
Ok(session.container_id.clone())
}
pub async fn write_file_to_container(
&self,
container_id: &str,
file_name: &str,
data: &[u8],
) -> Result<String, String> {
let docker = get_docker()?;
// Build a tar archive in memory containing the file
let mut tar_buf = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, file_name, data)
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: "/tmp".to_string(),
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", file_name))
}
} }

View File

@@ -90,6 +90,7 @@ pub fn run() {
commands::terminal_commands::terminal_input, commands::terminal_commands::terminal_input,
commands::terminal_commands::terminal_resize, commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session, commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal,
// Updates // Updates
commands::update_commands::get_app_version, commands::update_commands::get_app_version,
commands::update_commands::check_for_updates, commands::update_commands::check_for_updates,

View File

@@ -21,9 +21,10 @@ export default function TerminalView({ sessionId, active }: Props) {
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null); const webglRef = useRef<WebglAddon | null>(null);
const detectorRef = useRef<UrlDetector | null>(null); const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, resize, onOutput, onExit } = useTerminal(); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const [detectedUrl, setDetectedUrl] = useState<string | null>(null); const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -85,6 +86,40 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data); sendInput(sessionId, data);
}); });
// Handle image paste: intercept paste events with image data,
// upload to the container, and inject the file path into terminal input.
const handlePaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
e.preventDefault();
e.stopPropagation();
const blob = item.getAsFile();
if (!blob) return;
blob.arrayBuffer().then(async (buf) => {
try {
setImagePasteMsg("Uploading image...");
const data = new Uint8Array(buf);
const filePath = await pasteImage(sessionId, data);
// Inject the file path into terminal stdin
sendInput(sessionId, filePath);
setImagePasteMsg(`Image saved to ${filePath}`);
} catch (err) {
console.error("Image paste failed:", err);
setImagePasteMsg("Image paste failed");
}
});
return; // Only handle the first image
}
}
};
containerRef.current.addEventListener("paste", handlePaste, { capture: true });
// Handle backend output -> terminal // Handle backend output -> terminal
let aborted = false; let aborted = false;
@@ -129,6 +164,7 @@ export default function TerminalView({ sessionId, active }: Props) {
detector.dispose(); detector.dispose();
detectorRef.current = null; detectorRef.current = null;
inputDisposable.dispose(); inputDisposable.dispose();
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.()); outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.());
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
@@ -179,6 +215,13 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [detectedUrl]); }, [detectedUrl]);
// Auto-dismiss image paste message after 3 seconds
useEffect(() => {
if (!imagePasteMsg) return;
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
return () => clearTimeout(timer);
}, [imagePasteMsg]);
const handleOpenUrl = useCallback(() => { const handleOpenUrl = useCallback(() => {
if (detectedUrl) { if (detectedUrl) {
openUrl(detectedUrl).catch((e) => openUrl(detectedUrl).catch((e) =>
@@ -200,6 +243,14 @@ export default function TerminalView({ sessionId, active }: Props) {
onDismiss={() => setDetectedUrl(null)} onDismiss={() => setDetectedUrl(null)}
/> />
)} )}
{imagePasteMsg && (
<div
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
onClick={() => setImagePasteMsg(null)}
>
{imagePasteMsg}
</div>
)}
<div <div
ref={containerRef} ref={containerRef}
className="w-full h-full" className="w-full h-full"

View File

@@ -49,6 +49,14 @@ export function useTerminal() {
[], [],
); );
const pasteImage = useCallback(
async (sessionId: string, imageData: Uint8Array) => {
const bytes = Array.from(imageData);
return commands.pasteImageToTerminal(sessionId, bytes);
},
[],
);
const onOutput = useCallback( const onOutput = useCallback(
(sessionId: string, callback: (data: Uint8Array) => void) => { (sessionId: string, callback: (data: Uint8Array) => void) => {
const eventName = `terminal-output-${sessionId}`; const eventName = `terminal-output-${sessionId}`;
@@ -76,6 +84,7 @@ export function useTerminal() {
open, open,
close, close,
sendInput, sendInput,
pasteImage,
resize, resize,
onOutput, onOutput,
onExit, onExit,

View File

@@ -47,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
invoke<void>("terminal_resize", { sessionId, cols, rows }); invoke<void>("terminal_resize", { sessionId, cols, rows });
export const closeTerminalSession = (sessionId: string) => export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId }); invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
// Updates // Updates
export const getAppVersion = () => invoke<string>("get_app_version"); export const getAppVersion = () => invoke<string>("get_app_version");

View File

@@ -2,12 +2,13 @@
* Detects long URLs that span multiple hard-wrapped lines in PTY output. * Detects long URLs that span multiple hard-wrapped lines in PTY output.
* *
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width, * The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
* which breaks xterm.js WebLinksAddon URL detection. This class reassembles * which breaks xterm.js WebLinksAddon URL detection. This class flattens
* those wrapped URLs and fires a callback for ones >= 100 chars. * the buffer (stripping PTY wraps, converting blank lines to spaces) and
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
* *
* Two-phase approach: when a URL candidate extends to the end of the buffer, * When a URL match extends to the end of the flattened buffer, emission is
* emission is deferred (the rest of the URL may arrive in the next PTY chunk). * deferred (more chunks may still be arriving). A confirmation timer emits
* A confirmation timer emits the pending URL if no further data arrives. * the pending URL if no further data arrives within 500 ms.
*/ */
const ANSI_RE = const ANSI_RE =
@@ -57,38 +58,31 @@ export class UrlDetector {
} }
private scan(): void { private scan(): void {
// 1. Strip ANSI escape sequences
const clean = this.buffer.replace(ANSI_RE, ""); const clean = this.buffer.replace(ANSI_RE, "");
const lines = clean.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
// Remove trailing empty elements (artifacts of trailing \n from split) // 2. Flatten the buffer:
while (lines.length > 0 && lines[lines.length - 1] === "") { // - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
lines.pop(); // - Remaining \r and \n → removed (PTY hard-wrap artifacts)
} const flat = clean
.replace(/(\r?\n){2,}/g, " ")
.replace(/[\r\n]/g, "");
if (lines.length === 0) return; if (!flat) return;
for (let i = 0; i < lines.length; i++) { // 3. Match URLs on the flattened string — spans across wrapped lines naturally
const match = lines[i].match(/https?:\/\/[^\s'"]+/); const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
if (!match) continue; let m: RegExpExecArray | null;
// Start with the URL fragment found on this line while ((m = urlRe.exec(flat)) !== null) {
let url = match[0]; const url = m[0];
let lastLineIndex = i;
// Concatenate subsequent continuation lines (non-empty, no spaces, no leading whitespace)
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j];
if (!next || next.startsWith(" ") || next.includes(" ")) break;
url += next;
lastLineIndex = j;
i = j; // skip this line in the outer loop
}
// 4. Filter by length
if (url.length < MIN_URL_LENGTH) continue; if (url.length < MIN_URL_LENGTH) continue;
// If the URL reaches the last line of the buffer, the rest may still // 5. If the match extends to the very end of the flattened string,
// be arriving in the next PTY chunk — defer emission. // more chunks may still be arriving — defer emission.
if (lastLineIndex >= lines.length - 1) { if (m.index + url.length >= flat.length) {
this.pendingUrl = url; this.pendingUrl = url;
this.confirmTimer = setTimeout(() => { this.confirmTimer = setTimeout(() => {
this.confirmTimer = null; this.confirmTimer = null;
@@ -97,7 +91,7 @@ export class UrlDetector {
return; return;
} }
// URL is clearly complete (more content follows it in the buffer) // 6. URL is clearly complete (more content follows) — dedup + emit
this.pendingUrl = null; this.pendingUrl = null;
if (url !== this.lastEmitted) { if (url !== this.lastEmitted) {
this.lastEmitted = url; this.lastEmitted = url;
@@ -105,7 +99,7 @@ export class UrlDetector {
} }
} }
// Scan finished without finding a URL reaching the buffer end. // Scan finished without a URL at the buffer end.
// If we had a pending URL from a previous scan, it's now confirmed complete. // If we had a pending URL from a previous scan, it's now confirmed complete.
if (this.pendingUrl) { if (this.pendingUrl) {
this.emitPending(); this.emitPending();