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);
+ },
+ };
+}