diff --git a/e2e/fixtures/iip/termic-icon.iip b/e2e/fixtures/iip/termic-icon.iip new file mode 100644 index 00000000..a79c2060 --- /dev/null +++ b/e2e/fixtures/iip/termic-icon.iip @@ -0,0 +1 @@ +]1337;File=inline=1;size=1098;width=10;height=auto;preserveAspectRatio=1:iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEEUlEQVR42sVXzW8TVxCft/v8GSfOFxASKoVCQUKiUkt77akXjkjwFyC1x/Yv6b1ceusl58Kx4oYEFBEQKFUhrSAtKSbOxo5x7N19nd/sPnfj2t414LLSep9n3ryZ95t5M/M0pT+K3u4xo5juCKU6ywIZHifWY7LuDgLhO0LBpK2tBiASYFCtVr/izxV+V2JBM4Yhdi6UbfG75nnetX4d/QaIdZVKZdF13RtKqc/sBMU/xkRfWd38S6MUvoXAGHMnCIKLzWazlkRCJQwx8c5vQzkLHCAO2n6ggtBQznUIX0zSjqJuEJLrROKW7/MXFDfBL2oXIj6vWYARjMTnSZ3WAAScD9h54vex8kLIWzi9ME1zxTxtN9tULeZ4Z4rqrzt0rFKk3XZHhGdj/lwpj52S1+4Kv8783141yImgOYiN+Dp2h+i0kW4D44o1yMQ7+e7iBTp/bJbu/blDq3MVgXZzp0kfL83RZr0pk08yff1FnU7OVwT235n+yfI8PdjepUs/3qTQyHZ1Qsc1q1MngoXigDsUG62OT3u8o5YfUOOgKwhgvMfj/a4vczCO+L4gIHzIdPxBAb+S2LTSfROcZET6gaEnvBtAu/5XXRYHnPd5vFgu0MbLPZnLfhYaFgh4zsMXu3SE+ZDFGogZczjYezGnh51bwJbnwPr0+Dx9tDAj/49PFwWBBV78Q4a9nIvy2FKlRHRqifklMfJIudiTydvgVINzgx6WcCDg82JP6w0qsaJHLz3iEyF0BNZUTtNGbS8WUvSQ/Q0+YuAp7xwykMUaSg1PajotnXz70105YlgcUHvs76sXTtEXq0dlZ3gK2qF1NuCb63epWsj15uIopqWvkQZYFLocbPB9hxdsc2Ctzk7RiWqZWt0ooWEMGnglHbmlxTJwl0rJnTpLKYRyExukGY1Hf3v0a61Bt57VevNAA0/F4eUqlamK6XErCqL8g+oUrXDAnV2cic4Vj0ELjMlWg8c1YJKPHre4wh3PvH3aarzunQLkCdAk5ZpJGqCiwnPuaJXOLE73yBiDFsQ5dxwj3gABoj929+m516LHnBvwICGB5iiaMAI2l8rxUrbKHRq/bQyYNBcA5RMzZYn85sJ07xSAFhrK4oJsqXiYKEo0oN/gdHzrea2XsUCTBsWM12XrPsvCdASQB8q0nMgDy5IHysLLgECYnGGbTVuGt9JcYaTCuZLzUZDwYgyayQb9VlK37muX1/j9Eq3AoDtDyE6u5DX98MsT+nlzm3ZaB0Kf5/KMLgi8MBxqhV1zLakztSnt9xn+dMKQOv7hpjTPFTHvOIOgG9mUJm8/YhFa52Rb/l8XGCow3Ci3/W15KLV/YBy7ti3vv6CkXUwuo9r+XxeT93I1c0dYP4nLqXmTq/dEr+f/AKlDBsB7qxwDAAAAAElFTkSuQmCC \ No newline at end of file diff --git a/e2e/specs/agent.e2e.ts b/e2e/specs/agent.e2e.ts index 4e6a0dc4..bac32167 100644 --- a/e2e/specs/agent.e2e.ts +++ b/e2e/specs/agent.e2e.ts @@ -66,6 +66,53 @@ describe("agent working state", () => { }); }); +describe("inline images", () => { + let taskId!: string; + + after(async () => { + if (taskId) await archiveTask(taskId); + }); + + it("keeps IIP images visible through Pi's alternate-screen redraw", async () => { + await waitForAppShell(); + await requireTermicApi(); + taskId = await openTask("e2e-iip"); + await waitForAgentReady(taskId); + + const tabId = await browser.execute((id) => window.__termic!.useApp.getState().tabs[id][0].id, taskId); + const opaquePixelCount = () => browser.execute((id) => { + const layer = document.querySelector(`[data-terminal-host="${id}"] .xterm-image-layer`); + if (!(layer instanceof HTMLCanvasElement) || !layer.width || !layer.height) return 0; + const context = layer.getContext("2d"); + if (!context) return 0; + const pixels = context.getImageData(0, 0, layer.width, layer.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i]) count++; + return count; + }, tabId); + await submitToAgent(taskId, "#iip"); + + await browser.waitUntil( + () => browser.execute((id) => (window.__termic!.useApp.getState().tabs[id][0].liveTitle ?? "").endsWith("iip-after"), taskId), + { timeout: 10_000, timeoutMsg: "terminal parsing never resumed after the inline image" }, + ); + let previousOpaquePixels = 0; + let settledFrames = 0; + await browser.waitUntil( + async () => { + const opaquePixels = await opaquePixelCount(); + settledFrames = opaquePixels > 0 && opaquePixels === previousOpaquePixels ? settledFrames + 1 : 0; + previousOpaquePixels = opaquePixels; + return settledFrames >= 3; + }, + { timeout: 10_000, timeoutMsg: "Pi's redraw erased the inline image after rendering settled" }, + ); + await browser.pause(500); + expect(await opaquePixelCount()).toBeGreaterThan(0); + await snap("inline-image.png"); + }); +}); + // P0: when an agent you're NOT watching finishes, termic must raise attention // on its tab. Start an agent working, switch to another task so it's // backgrounded (still mounted), and assert its SIDEBAR row flags completion — diff --git a/scripts/fake-agent.sh b/scripts/fake-agent.sh index 57ad6650..f3fa4abb 100755 --- a/scripts/fake-agent.sh +++ b/scripts/fake-agent.sh @@ -65,6 +65,7 @@ set_title "✳ ${name}" # #osc9 TEXT emit an OSC 9 notification with a verbatim body, the way claude # asks for the user. BEL-terminated, as claude sends it. # #bel emit a REAL bell, distinct from the BEL that terminates an OSC. +# #iip emit an inline PNG, then Pi's alternate-screen redraw. osc9() { printf '\033]9;%s\007' "$1"; } spin() { for f in 0 1 2; do set_title "${SPINNER[$f]} ${name}"; sleep 0.15; done; } @@ -111,6 +112,29 @@ while IFS= read -r line; do "#bel") printf '\007' continue ;; + "#iip") + # Match Pi's IIP redraw: clear the screen, reserve image rows, emit the + # image from the last reserved row, then repeat after a layout shift. + rows=$(stty size <&0 2>/dev/null | cut -d' ' -f1) + rows=${rows:-24} + printf '\033[?1049h' + for top in 1 2; do + printf '\033[?2026h\033[2J' + image_row=$((top + 20)) + for ((row = 1; row <= rows; row++)); do + printf '\033[%s;1H\033[2K' "$row" + if ((row == image_row)); then + printf '\033[20A' + cat "$(dirname "$0")/../e2e/fixtures/iip/termic-icon.iip" + elif ((row == image_row + 1)); then + printf 'Pi redraw %s' "$top" + fi + done + printf '\033[?2026l' + sleep 0.1 + done + set_title "✳ ${name} iip-after" + continue ;; esac spin echo "FAKE-AGENT echo: ${line}" # streamed "response" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d22be97..2f83d952 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2789,8 +2789,29 @@ const RESERVED_PORT_NAMES: &[&str] = &[ "TERMIC_CLI", "TERMIC_CLI_HELP", "CONDUCTOR_PORT", "CONDUCTOR_WORKSPACE_NAME", "PORT", "PATH", "HOME", "SHELL", "USER", "TMPDIR", "PWD", "TERM", "LANG", "COLORFGBG", "COLORTERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", + "TMUX", "STY", "ZELLIJ", "KITTY_WINDOW_ID", "GHOSTTY_RESOURCES_DIR", + "WEZTERM_PANE", "WARP_SESSION_ID", "WARP_TERMINAL_SESSION_UUID", "WT_SESSION", ]; +// A PTY created by Termic is not still inside the terminal emulator that +// launched Termic. Clear inherited host markers before advertising the +// protocol Termic actually implements; otherwise Pi launched from Ghostty, +// for example, emits Kitty graphics instead of iTerm2 IIP. +const FOREIGN_TERMINAL_ENV: &[&str] = &[ + "TMUX", "STY", "ZELLIJ", "KITTY_WINDOW_ID", "GHOSTTY_RESOURCES_DIR", + "WEZTERM_PANE", "WARP_SESSION_ID", "WARP_TERMINAL_SESSION_UUID", "WT_SESSION", +]; + +fn configure_terminal_env(cmd: &mut CommandBuilder) { + for key in FOREIGN_TERMINAL_ENV { + cmd.env_remove(key); + } + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + cmd.env("TERM_PROGRAM", "iTerm.app"); + cmd.env("TERM_PROGRAM_VERSION", "3.5.0"); +} + /// A usable extra-named-port env var name: a POSIX env key /// (shell_env::is_env_key), not reserved, and not in the /// `TERMIC_PORT_` sibling-port namespace (a multi-repo member dir @@ -3096,8 +3117,6 @@ fn pty_spawn( ); } } - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); // Claim iTerm2 compatibility so agents that gate "fancy" OSC // emission on a known host (Claude Code's OSC 9 / OSC 9;4 // progress, OSC 133 shell-integration, OSC 1337 attention) @@ -3108,8 +3127,7 @@ fn pty_spawn( // 1337) — see TerminalPane's registerOscHandler calls — so the // claim is honest. Version string is high enough to clear common // feature-gate checks in agents that look for "iTerm2 ≥ 3.x". - cmd.env("TERM_PROGRAM", "iTerm.app"); - cmd.env("TERM_PROGRAM_VERSION", "3.5.0"); + configure_terminal_env(&mut cmd); let mut child = pair.slave.spawn_command(cmd).map_err(|e| { let s = e.to_string(); @@ -22489,6 +22507,21 @@ filename f.rs } } + #[test] + fn terminal_env_drops_inherited_host_identity() { + let mut cmd = CommandBuilder::new("true"); + for key in FOREIGN_TERMINAL_ENV { + cmd.env(key, "inherited"); + } + + configure_terminal_env(&mut cmd); + + for key in FOREIGN_TERMINAL_ENV { + assert!(cmd.get_env(key).is_none(), "{key} must not leak into the PTY"); + } + assert_eq!(cmd.get_env("TERM_PROGRAM"), Some(std::ffi::OsStr::new("iTerm.app"))); + } + #[test] fn reserved_names_match_ts_mirror() { // src/lib/namedPorts.ts carries a hand-maintained copy of diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d49c84b4..812011e9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,7 +13,7 @@ "withGlobalTauri": false, "windows": [], "security": { - "csp": "default-src 'self' ipc: http://ipc.localhost; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws: wss: https://termic.dev; worker-src 'self' blob:; frame-src 'none'; object-src 'self' taskpdf:" + "csp": "default-src 'self' ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws: wss: https://termic.dev; worker-src 'self' blob:; frame-src 'none'; object-src 'self' taskpdf:" } }, "bundle": { diff --git a/src/components/task/TerminalPane.tsx b/src/components/task/TerminalPane.tsx index 2027b29f..6c23617d 100644 --- a/src/components/task/TerminalPane.tsx +++ b/src/components/task/TerminalPane.tsx @@ -25,6 +25,7 @@ import { Osc52Base64 } from "@/lib/osc52"; import { makeCtrlSniffer } from "@/lib/ctrlSniffer"; import { attachCopyOnSelect } from "@/lib/terminalSelection"; import { ImageAddon } from "@xterm/addon-image"; +import { preserveImagesOnErase } from "@/lib/terminalImagePersistence"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { SearchAddon } from "@xterm/addon-search"; import { loadTerminalRenderer, awaitTerminalFonts } from "@/lib/terminalRenderer"; @@ -627,6 +628,9 @@ const captureArmedRef = useRef(false); term.loadAddon(searchAddon); searchAddonRef.current = searchAddon; term.loadAddon(new ImageAddon()); + const imagePersistence = /(?:^|\/)pi$/.test(spawnCommandForCli(tab.cli)) || (import.meta.env.VITE_E2E && tab.cli === "fakeagent") + ? preserveImagesOnErase(term) + : null; const unicode11 = new Unicode11Addon(); term.loadAddon(unicode11); term.unicode.activeVersion = "11"; @@ -2104,6 +2108,7 @@ const captureArmedRef = useRef(false); // closure — we must clear it here rather than in cancelSettle() because // the new effect's cancelSettle is a different closure instance. if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; } + imagePersistence?.dispose(); try { rendererAddon?.dispose(); } catch {} term.dispose(); termRef.current = null; diff --git a/src/lib/cspGuard.test.ts b/src/lib/cspGuard.test.ts index a5fca243..5c94ac2e 100644 --- a/src/lib/cspGuard.test.ts +++ b/src/lib/cspGuard.test.ts @@ -40,7 +40,7 @@ describe("tauri.conf.json CSP", () => { }); it("allows no remote script origin", () => { - expect(directive("script-src")).toBe("script-src 'self'"); + expect(directive("script-src")).toBe("script-src 'self' 'wasm-unsafe-eval'"); }); it("still declares a default-src fallback", () => { diff --git a/src/lib/namedPorts.ts b/src/lib/namedPorts.ts index 01508793..39afd441 100644 --- a/src/lib/namedPorts.ts +++ b/src/lib/namedPorts.ts @@ -8,6 +8,8 @@ export const RESERVED_PORT_NAMES: ReadonlySet = new Set([ "TERMIC_CLI", "TERMIC_CLI_HELP", "CONDUCTOR_PORT", "CONDUCTOR_WORKSPACE_NAME", "PORT", "PATH", "HOME", "SHELL", "USER", "TMPDIR", "PWD", "TERM", "LANG", "COLORFGBG", "COLORTERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", + "TMUX", "STY", "ZELLIJ", "KITTY_WINDOW_ID", "GHOSTTY_RESOURCES_DIR", + "WEZTERM_PANE", "WARP_SESSION_ID", "WARP_TERMINAL_SESSION_UUID", "WT_SESSION", ]); /** A usable extra-named-port env var name: `[A-Za-z_][A-Za-z0-9_]*`, diff --git a/src/lib/terminalImagePersistence.ts b/src/lib/terminalImagePersistence.ts new file mode 100644 index 00000000..063293e1 --- /dev/null +++ b/src/lib/terminalImagePersistence.ts @@ -0,0 +1,70 @@ +import type { Terminal } from "@xterm/xterm"; + +type ImageAttr = { imageId?: number }; +type ImageLine = { _data: Uint32Array; _extendedAttrs: Record }; +type InputHandler = { + _activeBuffer: { x: number; y: number; ybase: number; lines: { get(index: number): ImageLine | undefined } }; + eraseInLine(params: { params: ArrayLike }, respectProtect?: boolean): boolean; +}; + +/** Preserve IIP cells through Pi's alternate-screen full-line redraw. */ +export function preserveImagesOnErase(term: Terminal): { dispose(): void } { + const input = (term as unknown as { _core: { _inputHandler: InputHandler } })._core._inputHandler; + const original = input.eraseInLine; + const eraseInLine = original.bind(input); + let refreshStart = Infinity; + let refreshEnd = -1; + let frame1 = 0; + let frame2 = 0; + + const scheduleRefresh = (row: number) => { + refreshStart = Math.min(refreshStart, row); + refreshEnd = Math.max(refreshEnd, row); + if (frame1) return; + frame1 = requestAnimationFrame(() => { + frame1 = 0; + frame2 = requestAnimationFrame(() => { + frame2 = 0; + if (refreshEnd < 0) return; + const start = refreshStart; + const end = refreshEnd; + refreshStart = Infinity; + refreshEnd = -1; + term.refresh(start, end); + }); + }); + }; + + const wrapped: InputHandler["eraseInLine"] = (params, respectProtect) => { + if (term.buffer.active.type !== "alternate" || params.params[0] !== 2 || input._activeBuffer.x !== 0) + return eraseInLine(params, respectProtect); + + const row = input._activeBuffer.y; + const line = input._activeBuffer.lines.get(input._activeBuffer.ybase + row); + if (!line) return eraseInLine(params, respectProtect); + + const images: [number, number, ImageAttr][] = []; + for (let x = 0; x < term.cols; x++) { + const attr = line._extendedAttrs[x]; + if (attr?.imageId !== undefined && attr.imageId !== -1) + images.push([x, line._data[x * 3 + 2], attr]); + } + + const result = eraseInLine(params, respectProtect); + for (const [x, bg, attr] of images) { + line._data[x * 3 + 2] = bg; + line._extendedAttrs[x] = attr; + } + if (images.length) scheduleRefresh(row); + return result; + }; + input.eraseInLine = wrapped; + + return { + dispose() { + if (input.eraseInLine === wrapped) input.eraseInLine = original; + cancelAnimationFrame(frame1); + cancelAnimationFrame(frame2); + }, + }; +}