From 87508735367bacada03d41771ca09f4e5170954f Mon Sep 17 00:00:00 2001 From: xiaolai Date: Thu, 1 Oct 2026 01:09:27 +0800 Subject: [PATCH 1/2] fix(shell): keep the terminal mounted when it swaps sides Swapping the terminal between top and bottom (or left and right) killed every running shell, taking programs like a Claude session with it. EditorArea rendered the panel in one of two child slots depending on panelPosition, so a top<->bottom or left<->right swap moved it to the other slot and React unmounted and remounted TerminalPanel. The panel's unmount disposes all sessions, which kills their PTYs; the remount then started fresh shells. Auto mode rarely hit it because bottom and right share a slot. The panel now always occupies one slot after the editor column; top and left reverse the flex axis instead, so no node moves and nothing remounts. Reordering keyed children instead would have made React move the editor column, which reloads its iframes and drops focus. The cost is that for top/left the Tab order reaches the panel after the editor while it is drawn before; the two are independent landmarks. A regression test swaps a stateful panel through every position and asserts one mount; a WebKit test measures that each position still draws the panel on its side of the editor. --- src/shell/EditorArea.test.tsx | 40 +++++++++------ src/shell/EditorArea.tsx | 19 ++++++-- src/shell/panelPlacement.webkit.test.tsx | 62 ++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 src/shell/panelPlacement.webkit.test.tsx diff --git a/src/shell/EditorArea.test.tsx b/src/shell/EditorArea.test.tsx index 9410d88bf..7aab9ff3a 100644 --- a/src/shell/EditorArea.test.tsx +++ b/src/shell/EditorArea.test.tsx @@ -8,6 +8,7 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; +import { useEffect } from "react"; import { EditorArea } from "./EditorArea"; describe("EditorArea", () => { @@ -84,21 +85,9 @@ describe("EditorArea", () => { expect(screen.getByTestId("editor")).toBeInTheDocument(); }); - it("uses column layout when panel is top", () => { - const { container } = render( - editor} bottomBar={
bottom
} panel={
panel
} panelPosition="top" /> - ); - expect(container.firstChild as HTMLElement).toHaveStyle({ flexDirection: "column" }); - }); - - it("uses row layout when panel is left", () => { - const { container } = render( - editor} bottomBar={
bottom
} panel={
panel
} panelPosition="left" /> - ); - expect(container.firstChild as HTMLElement).toHaveStyle({ flexDirection: "row" }); - }); - - it.each(["top", "left"] as const)("renders the panel before the editor for %s", (panelPosition) => { + // The panel is drawn FIRST for top/left by reversing the flex axis, not by + // moving it to another child slot — see the remount regression test below. + it.each([["top", "column-reverse"], ["left", "row-reverse"]] as const)("draws the panel before the editor for %s", (panelPosition, flexDirection) => { const { container } = render( editor} @@ -108,7 +97,26 @@ describe("EditorArea", () => { /> ); const root = container.firstChild as HTMLElement; - expect(root.firstChild).toHaveAttribute("data-testid", "panel"); + expect(root).toHaveStyle({ flexDirection }); + expect(root.lastChild).toHaveAttribute("data-testid", "panel"); + }); + + // Regression: swapping the terminal between top and bottom (or left and + // right) moved the panel to a different child slot, so React unmounted it — + // and the terminal's unmount kills every PTY, taking running programs (a + // Claude session) with it. The panel must survive every position change. + it("keeps the panel mounted across every position change", () => { + let mounts = 0; + function Panel() { + useEffect(() => { mounts += 1; }, []); + return
panel
; + } + const area = (panelPosition: "top" | "bottom" | "left" | "right") => ( + editor} bottomBar={
bottom
} panel={} panelPosition={panelPosition} /> + ); + const { rerender } = render(area("bottom")); + for (const position of ["top", "bottom", "left", "right", "top", "left"] as const) rerender(area(position)); + expect(mounts).toBe(1); }); it.each(["bottom", "right"] as const)("renders the panel after the editor for %s", (panelPosition) => { diff --git a/src/shell/EditorArea.tsx b/src/shell/EditorArea.tsx index 71965ad19..4b85d421b 100644 --- a/src/shell/EditorArea.tsx +++ b/src/shell/EditorArea.tsx @@ -4,9 +4,17 @@ * * Per ADR-007, EditorArea is a pure layout helper — no store imports. * The dynamic panel positioning (top/bottom/left/right) is the only layout - * intelligence: left/right use a row axis, top/left render the panel before + * intelligence: left/right use a row axis, top/left draw the panel before * the editor. Everything else is pass-through composition. * + * The panel always occupies ONE child slot, after the editor column; top/left + * reverse the flex axis instead of moving it. Moving it between slots made + * React unmount and remount it on every top↔bottom / left↔right swap, and the + * terminal's unmount kills its PTYs — a running Claude session died with it. + * The cost is that for top/left the DOM (and so Tab) order puts the panel after + * the editor while it is drawn before; the two are independent landmarks, so + * no reading sequence depends on that order. + * * The editor + bottom-bar are siblings inside a flex column so the * 40px bottom bar always hugs the editor. The panel arranges around * that column based on panelPosition. @@ -56,21 +64,22 @@ export function EditorArea({ }: EditorAreaProps) { const { t } = useTranslation(); - // left/right share a row axis; top/left render the panel before the editor. + // left/right share a row axis; top/left draw the panel first by REVERSING + // the axis — never by moving the panel to another slot (see header). const horizontal = panelPosition === "left" || panelPosition === "right"; const panelFirst = panelPosition === "top" || panelPosition === "left"; + const axis = horizontal ? "row" : "column"; const panelAxis = (
- {panelFirst && panel}
- {!panelFirst && panel} + {panel}
); diff --git a/src/shell/panelPlacement.webkit.test.tsx b/src/shell/panelPlacement.webkit.test.tsx new file mode 100644 index 000000000..cc0351021 --- /dev/null +++ b/src/shell/panelPlacement.webkit.test.tsx @@ -0,0 +1,62 @@ +/** + * The panel is drawn on its side of the editor, measured in a real engine. + * + * EditorArea keeps the panel in ONE child slot and reverses the flex axis for + * top/left (moving it between slots remounted the terminal and killed its + * PTYs). jsdom has no layout, so only a real engine can show the reversed axis + * still puts the panel where the user asked for it. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import "@/styles/index.css"; +import { EditorArea } from "./EditorArea"; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +function mount(panelPosition: "top" | "bottom" | "left" | "right") { + host = document.createElement("div"); + host.style.cssText = "width: 800px; height: 600px; display: flex"; + document.body.append(host); + root = createRoot(host); + const horizontal = panelPosition === "left" || panelPosition === "right"; + flushSync(() => + root!.render( + } + bottomBar={
} + panel={
} + />, + ), + ); +} + +afterEach(() => { + root?.unmount(); + host?.remove(); + root = null; + host = null; +}); + +const box = (sel: string) => (document.querySelector(sel) as HTMLElement).getBoundingClientRect(); + +describe("panel placement (real engine, production EditorArea)", () => { + it("top: above the editor", () => { + mount("top"); + expect(box('[data-testid="panel"]').bottom).toBeLessThanOrEqual(box('[role="main"]').top + 0.5); + }); + it("bottom: below the editor and its bar", () => { + mount("bottom"); + expect(box('[data-testid="panel"]').top).toBeGreaterThanOrEqual(box('[role="main"]').bottom - 0.5); + }); + it("left: left of the editor", () => { + mount("left"); + expect(box('[data-testid="panel"]').right).toBeLessThanOrEqual(box('[role="main"]').left + 0.5); + }); + it("right: right of the editor", () => { + mount("right"); + expect(box('[data-testid="panel"]').left).toBeGreaterThanOrEqual(box('[role="main"]').right - 0.5); + }); +}); From 11d8048381ca1da942c806b0ae7682527a5a1db1 Mon Sep 17 00:00:00 2001 From: xiaolai Date: Thu, 1 Oct 2026 01:09:56 +0800 Subject: [PATCH 2/2] feat(terminal): render Claude/Codex transcripts beside the terminal A terminal cannot draw a Markdown table or a Mermaid diagram, so the replies where Claude Code or Codex answer with one arrive as pipes and source. With Settings > Terminal > "Automatic transcript rendering" on (off by default), VMark renders those replies beside the CLI. Binding: each shell gets an opaque token (VMARK_TRANSCRIPT_TOKEN). A SessionStart hook installed additively into ~/.claude/settings.json and ~/.codex/hooks.json records that CLI session's transcript path under the token, so every terminal follows its own session, never "the most recently modified transcript". Config is written only when the hook is missing, and invalid user config is refused rather than replaced. Disabling removes the enabled marker and every binding; closing or restarting a shell deletes its binding. Reading: terminal_transcript_read is bounded to the last 2 MiB, confined to canonical paths under the CLIs' session directories, returns only when the file changed, and treats a transcript announced but not yet written as waiting. Parsing keeps complete JSONL lines and the last 100 assistant messages. Rendering: Markdown becomes React elements (no raw HTML, links or remote images); Mermaid renders with strict security and SVG labels into a scriptless sandboxed iframe, then restores the editor's config. The region sits right of the CLI in a top/bottom panel and below it in a side panel, starts collapsed, and opens once per new reply that holds a table or diagram. A pressed-state chart toggle in the tab bar shows or hides it; hidden, it leaves the layout. Documented in terminal.md and settings.md in all ten locales. --- .claude/feature-ledger.md | 2 +- scripts/feature-map.json | 2 + .../lib/docJoins/settingsDefaults.test.mjs | 14 +- .../lib/docJoins/settingsDefaultsRowMap.mjs | 2 + scripts/terminal-transcript-hook.test.mjs | 26 ++ .../resources/terminal-transcript-hook.cjs | 19 ++ src-tauri/src/command_registry.rs | 4 + src-tauri/src/lib.rs | 3 + src-tauri/src/lib.test.rs | 1 + src-tauri/src/terminal_transcript/config.rs | 57 ++++ src-tauri/src/terminal_transcript/mod.rs | 276 ++++++++++++++++++ src-tauri/src/terminal_transcript/tests.rs | 160 ++++++++++ .../TerminalPanel.transcript.test.tsx | 92 ++++++ src/components/Terminal/TerminalPanel.tsx | 24 +- .../Terminal/TerminalSessionsArea.test.tsx | 24 ++ .../Terminal/TerminalSessionsArea.tsx | 14 + .../Terminal/TerminalTabBar.test.tsx | 30 ++ src/components/Terminal/TerminalTabBar.tsx | 28 +- .../Terminal/TerminalTranscript.test.tsx | 23 ++ .../Terminal/TerminalTranscript.tsx | 15 + .../Terminal/TranscriptMarkdown.test.tsx | 32 ++ .../Terminal/TranscriptMarkdown.tsx | 34 +++ .../Terminal/TranscriptMermaid.test.tsx | 15 + src/components/Terminal/TranscriptMermaid.tsx | 24 ++ src/components/Terminal/spawnPty.ts | 12 + src/components/Terminal/terminal-panel.css | 17 ++ .../Terminal/terminal-transcript.css | 28 ++ .../Terminal/terminalSessionRegistry.ts | 4 + .../terminalTranscriptLayout.webkit.test.tsx | 90 ++++++ .../Terminal/transcriptMarkdownTree.test.ts | 30 ++ .../Terminal/transcriptMarkdownTree.ts | 22 ++ .../Terminal/useRenderedTranscript.test.tsx | 40 +++ .../Terminal/useRenderedTranscript.ts | 15 + .../Terminal/useTerminalShellLifecycle.ts | 1 + .../Terminal/useTerminalTranscript.test.tsx | 54 ++++ .../Terminal/useTerminalTranscript.ts | 53 ++++ .../Terminal/useTranscriptAutoOpen.test.tsx | 52 ++++ .../Terminal/useTranscriptAutoOpen.ts | 46 +++ .../useTranscriptConfiguration.test.tsx | 28 ++ .../Terminal/useTranscriptConfiguration.ts | 21 ++ src/locales/de/settings.json | 8 +- src/locales/en/settings.json | 8 +- src/locales/es/settings.json | 8 +- src/locales/fr/settings.json | 8 +- src/locales/it/settings.json | 8 +- src/locales/ja/settings.json | 8 +- src/locales/ko/settings.json | 8 +- src/locales/pt-BR/settings.json | 8 +- src/locales/zh-CN/settings.json | 8 +- src/locales/zh-TW/settings.json | 8 +- src/pages/settings/TerminalSettings.test.tsx | 8 + src/pages/settings/TerminalSettings.tsx | 4 + src/plugins/mermaid/plugin.test.ts | 8 + src/plugins/mermaid/plugin.ts | 13 +- .../mermaid/strictRender.webkit.test.ts | 28 ++ .../terminal/transcriptBinding.test.ts | 42 +++ src/services/terminal/transcriptBinding.ts | 31 ++ src/stores/settingsStore/defaults.ts | 1 + src/stores/settingsTypes/system.ts | 1 + src/utils/terminalTranscript.test.ts | 26 ++ src/utils/terminalTranscript.ts | 22 ++ website/de/guide/settings.md | 1 + website/de/guide/terminal.md | 11 + website/es/guide/settings.md | 1 + website/es/guide/terminal.md | 11 + website/fr/guide/settings.md | 1 + website/fr/guide/terminal.md | 11 + website/guide/settings.md | 1 + website/guide/terminal.md | 11 + website/it/guide/settings.md | 1 + website/it/guide/terminal.md | 11 + website/ja/guide/settings.md | 1 + website/ja/guide/terminal.md | 11 + website/ko/guide/settings.md | 1 + website/ko/guide/terminal.md | 11 + website/pt-BR/guide/settings.md | 1 + website/pt-BR/guide/terminal.md | 11 + website/zh-CN/guide/settings.md | 1 + website/zh-CN/guide/terminal.md | 11 + website/zh-TW/guide/settings.md | 1 + website/zh-TW/guide/terminal.md | 11 + 81 files changed, 1789 insertions(+), 29 deletions(-) create mode 100644 scripts/terminal-transcript-hook.test.mjs create mode 100644 src-tauri/resources/terminal-transcript-hook.cjs create mode 100644 src-tauri/src/terminal_transcript/config.rs create mode 100644 src-tauri/src/terminal_transcript/mod.rs create mode 100644 src-tauri/src/terminal_transcript/tests.rs create mode 100644 src/components/Terminal/TerminalPanel.transcript.test.tsx create mode 100644 src/components/Terminal/TerminalSessionsArea.test.tsx create mode 100644 src/components/Terminal/TerminalSessionsArea.tsx create mode 100644 src/components/Terminal/TerminalTranscript.test.tsx create mode 100644 src/components/Terminal/TerminalTranscript.tsx create mode 100644 src/components/Terminal/TranscriptMarkdown.test.tsx create mode 100644 src/components/Terminal/TranscriptMarkdown.tsx create mode 100644 src/components/Terminal/TranscriptMermaid.test.tsx create mode 100644 src/components/Terminal/TranscriptMermaid.tsx create mode 100644 src/components/Terminal/terminal-transcript.css create mode 100644 src/components/Terminal/terminalTranscriptLayout.webkit.test.tsx create mode 100644 src/components/Terminal/transcriptMarkdownTree.test.ts create mode 100644 src/components/Terminal/transcriptMarkdownTree.ts create mode 100644 src/components/Terminal/useRenderedTranscript.test.tsx create mode 100644 src/components/Terminal/useRenderedTranscript.ts create mode 100644 src/components/Terminal/useTerminalTranscript.test.tsx create mode 100644 src/components/Terminal/useTerminalTranscript.ts create mode 100644 src/components/Terminal/useTranscriptAutoOpen.test.tsx create mode 100644 src/components/Terminal/useTranscriptAutoOpen.ts create mode 100644 src/components/Terminal/useTranscriptConfiguration.test.tsx create mode 100644 src/components/Terminal/useTranscriptConfiguration.ts create mode 100644 src/plugins/mermaid/strictRender.webkit.test.ts create mode 100644 src/services/terminal/transcriptBinding.test.ts create mode 100644 src/services/terminal/transcriptBinding.ts create mode 100644 src/utils/terminalTranscript.test.ts create mode 100644 src/utils/terminalTranscript.ts diff --git a/.claude/feature-ledger.md b/.claude/feature-ledger.md index 283620fb4..a9118c374 100644 --- a/.claude/feature-ledger.md +++ b/.claude/feature-ledger.md @@ -2269,7 +2269,7 @@ The panel is lazy-imported in `src/App.tsx` behind a `FeatureErrorBoundary`; `sr - id: terminal-panel - feature: Integrated terminal - summary: A dockable shell panel on any side of the editor, toggled with Ctrl+`, with drag-resize, double-click maximize, and an empty-state hint when no session can be created. -- capabilities: toggle via shortcut, View menu, status-bar button or command palette; opening moves the caret into the shell and closing returns it to the editor only when nothing else owns focus; docking top/bottom/left/right/auto/auto-flipped with aspect-ratio auto-placement (ratio ≥ 1.5 → right, ≤ 0.85 → bottom, 1440 px width threshold with 50 px hysteresis in between); drag-to-resize handle on the editor-adjacent edge; double-click on the handle toggles maximize to the 80% cap and back without rewriting the stored ratio; panelRatio persisted on drag end; deferred xterm activation until first show; ResizeObserver auto-refit; `terminal-resizing` class suppresses transitions during drag; localized empty-state hint `terminal.noWorkspaceSession` +- capabilities: opt-in `terminal.transcriptPreview` automatically follows exact Claude/Codex SessionStart transcript bindings and renders assistant Markdown, selectable GFM tables and sandboxed Mermaid in a collapsible section beside the CLI (right of it in a top/bottom panel, below it in a side panel); collapsed by default and auto-opened once per new reply containing a table or Mermaid block; shown/hidden by a pressed-state chart toggle (`data-terminal-action="transcript"`) in the tab bar actions; hidden panels stop reads; toggle via shortcut, View menu, status-bar button or command palette; opening moves the caret into the shell and closing returns it to the editor only when nothing else owns focus; docking top/bottom/left/right/auto/auto-flipped with aspect-ratio auto-placement (ratio ≥ 1.5 → right, ≤ 0.85 → bottom, 1440 px width threshold with 50 px hysteresis in between); drag-to-resize handle on the editor-adjacent edge; double-click on the handle toggles maximize to the 80% cap and back without rewriting the stored ratio; panelRatio persisted on drag end; deferred xterm activation until first show; ResizeObserver auto-refit; `terminal-resizing` class suppresses transitions during drag; localized empty-state hint `terminal.noWorkspaceSession` - status: shipped-on - gate: always on; `terminal.position = "auto"`, `terminal.panelRatio = 0.4`; opening refused with a toast by `canOpenTerminal()` unless workspace mode is on or the active tab has a saved file - surfaces: menu id `toggle-terminal` (View menu, accel `Ctrl+\``); shortcut id `toggleTerminal` (default `Ctrl-\``); status-bar terminal button; command palette `view.toggleTerminal`; resize handle diff --git a/scripts/feature-map.json b/scripts/feature-map.json index cbfc9045c..f4229eb24 100644 --- a/scripts/feature-map.json +++ b/scripts/feature-map.json @@ -795,6 +795,8 @@ "src-tauri/src/pty", "src-tauri/src/shell_env.rs", "src-tauri/src/shell_integration.rs", + "src-tauri/src/terminal_transcript", + "src/utils/terminalTranscript.ts", "src/lib/pty.ts", "src/services/terminalAttention.ts", "src/pages/settings/TerminalSettings.tsx", diff --git a/scripts/lib/docJoins/settingsDefaults.test.mjs b/scripts/lib/docJoins/settingsDefaults.test.mjs index 73590f399..bd2478764 100644 --- a/scripts/lib/docJoins/settingsDefaults.test.mjs +++ b/scripts/lib/docJoins/settingsDefaults.test.mjs @@ -79,6 +79,7 @@ const TERMINAL_DOC = ` | Font Size | 10 – 24 px | 13 px | All | | Line Height | 1.0 – 2.0 | 1.2 | All | | Copy on Select | On / Off | Off | All | +| Automatic transcript rendering | On / Off | Off | All | | Mac Option as Meta | On / Off | On | macOS | | Shell Integration | On / Off | On | macOS / Linux (zsh, bash) | | Remote Clipboard (OSC 52) | On / Off | On | All | @@ -100,6 +101,7 @@ const TERMINAL_DEFAULTS = { fontSize: 13, lineHeight: 1.2, copyOnSelect: false, + transcriptPreview: false, macOptionIsMeta: true, shellIntegration: true, osc52Clipboard: true, @@ -446,18 +448,18 @@ describe("compare", () => { // --------------------------------------------------------------------------- describe("terminal.md Default column ↔ defaults (formerly terminalDocDefaults.test.ts)", () => { - it("maps all eleven published terminal rows and finds them all correct against the shipped defaults", () => { - expect(TERMINAL_MAP).toHaveLength(11); + it("maps all twelve published terminal rows and finds them all correct against the shipped defaults", () => { + expect(TERMINAL_MAP).toHaveLength(12); const { findings, info } = compare(pagesFrom({ terminal: TERMINAL_DOC }), TERMINAL_DEFAULTS, TERMINAL_MAP); expect(findings).toEqual([]); - expect(info).toContain("terminal.md: 11 Default rows, 11 mapped"); + expect(info).toContain("terminal.md: 12 Default rows, 12 mapped"); }); it("catches T9 — Option-as-Meta documented Off while the code ships true", () => { const doc = TERMINAL_DOC.replace("| Mac Option as Meta | On / Off | On |", "| Mac Option as Meta | On / Off | Off |"); const { findings } = compare(pagesFrom({ terminal: doc }), TERMINAL_DEFAULTS, TERMINAL_MAP); expect(findings).toEqual([ - 'terminal.md:10 "Mac Option as Meta": doc says "Off", code (terminal.macOptionIsMeta) says "On"', + 'terminal.md:11 "Mac Option as Meta": doc says "Off", code (terminal.macOptionIsMeta) says "On"', ]); }); @@ -551,7 +553,7 @@ describe("run", () => { const { findings, info } = await run({ root, paths, deps: { defaults, rowMap } }); expect(findings).toEqual(['docs/settings.md:3 "Confirm quit": doc says "On", code (general.confirmQuit) says "Off"']); expect(info).toContain("defaults: injected by the caller"); - expect(info).toContain("docs/terminal.md: 11 Default rows, 11 mapped"); + expect(info).toContain("docs/terminal.md: 12 Default rows, 12 mapped"); }); it("LIVE: settings.md and terminal.md agree with defaults.ts in both directions", async () => { @@ -564,7 +566,7 @@ describe("run", () => { .filter(Boolean) .map((m) => [m[1], { rows: Number(m[2]), mapped: Number(m[3]) }]), ); - expect(rows["website/guide/terminal.md"]).toEqual({ rows: 11, mapped: 11 }); + expect(rows["website/guide/terminal.md"]).toEqual({ rows: 12, mapped: 12 }); expect(rows["website/guide/settings.md"].rows).toBeGreaterThan(100); expect(rows["website/guide/settings.md"].mapped).toBe(rows["website/guide/settings.md"].rows); }); diff --git a/scripts/lib/docJoins/settingsDefaultsRowMap.mjs b/scripts/lib/docJoins/settingsDefaultsRowMap.mjs index 56fb76255..936daed6d 100644 --- a/scripts/lib/docJoins/settingsDefaultsRowMap.mjs +++ b/scripts/lib/docJoins/settingsDefaultsRowMap.mjs @@ -195,6 +195,7 @@ export const ROW_MAP = [ settings("Cursor Style", "terminal.cursorStyle", { enum: { bar: "Bar", block: "Block", underline: "Underline" } }), settings("Cursor Blink", "terminal.cursorBlink", ON_OFF), settings("Copy on Select", "terminal.copyOnSelect", ON_OFF), + settings("Automatic transcript rendering", "terminal.transcriptPreview", ON_OFF), settings("WebGL Renderer", "terminal.useWebGL", ON_OFF), settings("Remote Clipboard (OSC 52)", "terminal.osc52Clipboard", ON_OFF), settings("Scrollback", "terminal.scrollback", "thousands"), @@ -225,6 +226,7 @@ export const ROW_MAP = [ terminal("Font Size", "terminal.fontSize", "px"), terminal("Line Height", "terminal.lineHeight", "number"), terminal("Copy on Select", "terminal.copyOnSelect", ON_OFF), + terminal("Automatic transcript rendering", "terminal.transcriptPreview", ON_OFF), terminal("Mac Option as Meta", "terminal.macOptionIsMeta", ON_OFF), terminal("Shell Integration", "terminal.shellIntegration", ON_OFF), terminal("Remote Clipboard (OSC 52)", "terminal.osc52Clipboard", ON_OFF), diff --git a/scripts/terminal-transcript-hook.test.mjs b/scripts/terminal-transcript-hook.test.mjs new file mode 100644 index 000000000..c3e19b397 --- /dev/null +++ b/scripts/terminal-transcript-hook.test.mjs @@ -0,0 +1,26 @@ +// WI-TP1.1: hook handshake without touching real CLI configuration. +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { mkdtempSync, copyFileSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +const token = 'cb28fc00-2c1b-4eaf-9d09-71d9d5392926'; +test('SessionStart binds exactly, is silent, and ignores disabled/foreign invocations', () => { + const root = mkdtempSync(join(tmpdir(), 'vmark-transcript-')); + try { + const script = join(root, 'terminal-transcript-hook.cjs'); + copyFileSync('src-tauri/resources/terminal-transcript-hook.cjs', script); + const input = JSON.stringify({ hook_event_name: 'SessionStart', session_id: 'exact', transcript_path: '/tmp/exact.jsonl' }); + const run = (env = {}) => spawnSync(process.execPath, [script], { env: { ...process.env, ...env }, input, encoding: 'utf8' }); + const dest = join(root, token + '.json'); + assert.equal(run({ VMARK_TRANSCRIPT_TOKEN: token }).status, 0); + assert.equal(existsSync(dest), false); + writeFileSync(join(root, 'enabled'), 'enabled'); + assert.equal(run({ VMARK_TRANSCRIPT_TOKEN: '../invalid' }).status, 0); + assert.equal(existsSync(dest), false); + const result = run({ VMARK_TRANSCRIPT_TOKEN: token }); + assert.equal(result.status, 0); assert.equal(result.stdout, ''); assert.equal(result.stderr, ''); + assert.deepEqual(JSON.parse(readFileSync(dest, 'utf8')), { path: '/tmp/exact.jsonl', sessionId: 'exact' }); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/src-tauri/resources/terminal-transcript-hook.cjs b/src-tauri/resources/terminal-transcript-hook.cjs new file mode 100644 index 000000000..dff1c784b --- /dev/null +++ b/src-tauri/resources/terminal-transcript-hook.cjs @@ -0,0 +1,19 @@ +// VMark SessionStart hook: never emits model context; outside VMark, no-op. +const fs = require('node:fs'); +const path = require('node:path'); +const token = process.env.VMARK_TRANSCRIPT_TOKEN; +const root = __dirname; +if (!/^[a-f0-9-]{36}$/i.test(token || '') || !fs.existsSync(path.join(root, 'enabled'))) process.exit(0); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { input += chunk; if (input.length > 65536) process.exit(0); }); +process.stdin.on('end', () => { + try { + const value = JSON.parse(input); + if (value.hook_event_name !== 'SessionStart' || typeof value.transcript_path !== 'string' || !path.isAbsolute(value.transcript_path)) return; + const dest = path.join(root, token + '.json'); + const tmp = dest + '.' + process.pid; + fs.writeFileSync(tmp, JSON.stringify({ path: value.transcript_path, sessionId: value.session_id }), { mode: 0o600 }); + fs.renameSync(tmp, dest); + } catch { /* Preview must never prevent the CLI from starting. */ } +}); diff --git a/src-tauri/src/command_registry.rs b/src-tauri/src/command_registry.rs index c8dc8d9d1..45d3fdb19 100644 --- a/src-tauri/src/command_registry.rs +++ b/src-tauri/src/command_registry.rs @@ -213,6 +213,10 @@ macro_rules! all_commands { pty::pty_pause, pty::pty_resume, shell_integration::prepare_shell_integration, + terminal_transcript::terminal_transcript_prepare, + terminal_transcript::terminal_transcript_configure, + terminal_transcript::terminal_transcript_read, + terminal_transcript::terminal_transcript_forget, system_fonts::list_system_font_families, ] }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d02690d18..80959dca3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,6 +66,7 @@ mod system_fonts; mod tab_transfer; mod task; mod temp_html; +mod terminal_transcript; mod trusted_html; // #1273 opt-in origin-isolated execution for standalone HTML mod watcher; mod webview_edit; @@ -145,6 +146,8 @@ fn manage_state(builder: tauri::Builder) -> tauri::Builder // WI-LX1.1: the workspace roots the user chose. Loaded from app data // and re-granted in `setup_app`; picks made before that are merged. .manage(workspace_grants::WorkspaceGrants::default()) + // Serializes terminal-transcript CLI hook configuration writes. + .manage(terminal_transcript::TranscriptConfigState::default()) } /// Build and run the Tauri application with all plugins, commands, and event handlers. diff --git a/src-tauri/src/lib.test.rs b/src-tauri/src/lib.test.rs index 27fed0801..2a34289da 100644 --- a/src-tauri/src/lib.test.rs +++ b/src-tauri/src/lib.test.rs @@ -82,5 +82,6 @@ fn manage_state_registers_every_backend_state() { crate::trusted_html::TrustedHtmlState, crate::close_to_tray::CloseToTrayState, crate::workspace_grants::WorkspaceGrants, + crate::terminal_transcript::TranscriptConfigState, ); } diff --git a/src-tauri/src/terminal_transcript/config.rs b/src-tauri/src/terminal_transcript/config.rs new file mode 100644 index 000000000..2df2eb65d --- /dev/null +++ b/src-tauri/src/terminal_transcript/config.rs @@ -0,0 +1,57 @@ +//! Additive, idempotent hook configuration. Invalid user config is never replaced. +use crate::command_error::CommandError; +use serde_json::{json, Value}; +use std::path::Path; +/// Returns whether the config changed — callers write the file only then, so an +/// already-configured CLI file is never rewritten (or reformatted). +pub(super) fn add_hook(config: &mut Value, command: &str) -> Result { + let object = config + .as_object_mut() + .ok_or_else(|| CommandError::invalid_input("CLI configuration must be an object"))?; + let hooks = object + .entry("hooks") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| CommandError::invalid_input("CLI hooks must be an object"))?; + let groups = hooks + .entry("SessionStart") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| CommandError::invalid_input("SessionStart hooks must be an array"))?; + if groups.iter().any(|group| { + group["hooks"] + .as_array() + .is_some_and(|hooks| hooks.iter().any(|hook| hook["command"] == command)) + }) { + return Ok(false); + } + groups.push(json!({"hooks":[{"type":"command", "command": command, "timeout": 5}]})); + Ok(true) +} +pub(super) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), CommandError> { + let temporary = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|e| CommandError::io(e.to_string()))?; + { + use std::io::Write; + file.write_all(bytes) + .map_err(|e| CommandError::io(e.to_string()))?; + } + if let Ok(meta) = std::fs::metadata(path) { + std::fs::set_permissions(&temporary, meta.permissions()) + .map_err(|e| CommandError::io(e.to_string()))?; + } + drop(file); + std::fs::rename(&temporary, path).map_err(|e| { + let _ = std::fs::remove_file(&temporary); + CommandError::io(e.to_string()) + }) +} diff --git a/src-tauri/src/terminal_transcript/mod.rs b/src-tauri/src/terminal_transcript/mod.rs new file mode 100644 index 000000000..f7fe68293 --- /dev/null +++ b/src-tauri/src/terminal_transcript/mod.rs @@ -0,0 +1,276 @@ +//! Exact terminal-to-transcript bindings, delivered by CLI SessionStart hooks. +//! Reads are bounded, canonical-root confined, and never touch PTY output. +use crate::command_error::CommandError; +use serde::Serialize; +use serde_json::{json, Value}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager, Runtime, State}; +mod config; +use config::add_hook; +#[cfg(test)] +mod tests; +/// Serializes CLI config writes; a newer request supersedes queued older ones. +#[derive(Default)] +pub struct TranscriptConfigState(Arc); +#[derive(Default)] +struct ConfigGate { + lock: Mutex<()>, + revision: AtomicU64, +} +const LIMIT: u64 = 2 * 1024 * 1024; +fn valid_token(token: &str) -> bool { + uuid::Uuid::parse_str(token).is_ok() +} +fn directory(app: &AppHandle) -> Result { + app.path() + .app_local_data_dir() + .map(|p| p.join("terminal-transcripts")) + .map_err(|e| CommandError::io(e.to_string())) +} +/// Allocate an opaque binding for this shell, even if preview is currently off. +#[tauri::command] +pub fn terminal_transcript_prepare() -> String { + uuid::Uuid::new_v4().to_string() +} +/// Install additive CLI hooks, or deactivate installed hooks without deleting user config. +#[tauri::command] +pub async fn terminal_transcript_configure( + app: AppHandle, + state: State<'_, TranscriptConfigState>, + enabled: bool, +) -> Result<(), CommandError> { + let root = directory(&app)?; + let gate = Arc::clone(&state.0); + let generation = gate.revision.fetch_add(1, Ordering::SeqCst) + 1; + tauri::async_runtime::spawn_blocking(move || { + let _guard = gate + .lock + .lock() + .map_err(|e| CommandError::internal(e.to_string()))?; + if generation != gate.revision.load(Ordering::SeqCst) { + return Ok(()); + } + let (claude, codex) = cli_roots()?; + configure(&root, enabled, &claude, &codex) + }) + .await + .map_err(|e| CommandError::internal(e.to_string()))? +} +fn configure(root: &Path, enabled: bool, claude: &Path, codex: &Path) -> Result<(), CommandError> { + std::fs::create_dir_all(root).map_err(|e| CommandError::io(e.to_string()))?; + let marker = root.join("enabled"); + if !enabled { + if marker.exists() { + std::fs::remove_file(marker).map_err(|e| CommandError::io(e.to_string()))?; + } + return prune_bindings(root); + } + let script = root.join("terminal-transcript-hook.cjs"); + std::fs::write( + &script, + include_str!("../../resources/terminal-transcript-hook.cjs"), + ) + .map_err(|e| CommandError::io(e.to_string()))?; + let command = if cfg!(windows) { + format!("node \"{}\"", script.display()) + } else { + format!("node '{}'", script.to_string_lossy().replace('\'', "'\\''")) + }; + // Parse and validate BOTH before writing either, so malformed config is preserved. + let paths = [claude.join("settings.json"), codex.join("hooks.json")]; + let mut configs = Vec::new(); + for path in &paths { + let mut value: Value = if path.exists() { + serde_json::from_slice( + &std::fs::read(path).map_err(|e| CommandError::io(e.to_string()))?, + ) + .map_err(|e| CommandError::invalid_input(e.to_string()))? + } else { + json!({}) + }; + if add_hook(&mut value, &command)? { + configs.push((path, value)); + } + } + for (path, value) in configs { + std::fs::create_dir_all(path.parent().unwrap()) + .map_err(|e| CommandError::io(e.to_string()))?; + config::write_atomic( + path, + &serde_json::to_vec_pretty(&value) + .map_err(|e| CommandError::internal(e.to_string()))?, + )?; + } + std::fs::write(marker, b"enabled").map_err(|e| CommandError::io(e.to_string()))?; + Ok(()) +} +/// The Claude and Codex config roots, honouring their override variables. +fn cli_roots() -> Result<(PathBuf, PathBuf), CommandError> { + let home = + dirs::home_dir().ok_or_else(|| CommandError::not_found("Home directory unavailable"))?; + Ok(( + config_root("CLAUDE_CONFIG_DIR", home.join(".claude")), + config_root("CODEX_HOME", home.join(".codex")), + )) +} +fn config_root(key: &str, fallback: PathBuf) -> PathBuf { + std::env::var_os(key) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .unwrap_or(fallback) +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptSnapshot { + revision: String, + data: Option, +} +/// A missing binding is a normal waiting state. Caller supplies no filesystem path. +#[tauri::command] +pub async fn terminal_transcript_read( + app: AppHandle, + token: String, + revision: Option, +) -> Result, CommandError> { + if !valid_token(&token) { + return Err(CommandError::invalid_input("Invalid transcript token")); + } + let root = directory(&app)?; + let (claude, codex) = cli_roots()?; + let roots = [claude.join("projects"), codex.join("sessions")]; + tauri::async_runtime::spawn_blocking(move || { + read_snapshot(&root, &roots, &token, revision.as_deref()) + }) + .await + .map_err(|e| CommandError::internal(e.to_string()))? +} +/// Delete a shell's binding when its session closes or its shell is replaced. +#[tauri::command] +pub async fn terminal_transcript_forget( + app: AppHandle, + token: String, +) -> Result<(), CommandError> { + if !valid_token(&token) { + return Err(CommandError::invalid_input("Invalid transcript token")); + } + let root = directory(&app)?; + tauri::async_runtime::spawn_blocking(move || remove_binding(&root, &token)) + .await + .map_err(|e| CommandError::internal(e.to_string()))? +} +fn remove_binding(root: &Path, token: &str) -> Result<(), CommandError> { + match std::fs::remove_file(root.join(format!("{token}.json"))) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(CommandError::io(e.to_string())), + _ => Ok(()), + } +} +/// Disabling drops every binding; enabled shells re-bind on their next CLI start. +fn prune_bindings(root: &Path) -> Result<(), CommandError> { + let entries = std::fs::read_dir(root).map_err(|e| CommandError::io(e.to_string()))?; + for entry in entries { + let path = entry.map_err(|e| CommandError::io(e.to_string()))?.path(); + let is_binding = path.extension().is_some_and(|ext| ext == "json") + && path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(valid_token); + if is_binding { + std::fs::remove_file(&path).map_err(|e| CommandError::io(e.to_string()))?; + } + } + Ok(()) +} +fn allowed_path(path: &Path, roots: &[PathBuf]) -> bool { + path.extension().is_some_and(|ext| ext == "jsonl") + && roots + .iter() + .any(|root| root.canonicalize().is_ok_and(|root| path.starts_with(root))) +} +fn read_snapshot( + root: &Path, + roots: &[PathBuf], + token: &str, + previous: Option<&str>, +) -> Result, CommandError> { + if !root.join("enabled").exists() { + return Ok(None); + } + let binding = root.join(format!("{token}.json")); + let bytes = match std::fs::read(&binding) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(CommandError::io(e.to_string())), + }; + let value: Value = + serde_json::from_slice(&bytes).map_err(|e| CommandError::invalid_input(e.to_string()))?; + let path = value["path"] + .as_str() + .ok_or_else(|| CommandError::invalid_input("Missing transcript path"))?; + // The CLI announces its transcript before writing it: absent is still waiting. + let path = match Path::new(path).canonicalize() { + Ok(path) => path, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(CommandError::io(e.to_string())), + }; + if !allowed_path(&path, roots) { + return Err(CommandError::permission_denied( + "Transcript outside CLI session directories", + )); + } + let meta = std::fs::metadata(&path).map_err(|e| CommandError::io(e.to_string()))?; + let revision = format!( + "{}:{:?}:{}:{:?}", + path.display(), + value["sessionId"], + meta.len(), + meta.modified() + ); + if Some(revision.as_str()) == previous { + return Ok(Some(TranscriptSnapshot { + revision, + data: None, + })); + } + Ok(Some(TranscriptSnapshot { + revision, + data: Some(read_tail(&path, LIMIT)?), + })) +} +fn read_tail(path: &Path, limit: u64) -> Result { + let mut file = std::fs::File::open(path).map_err(|e| CommandError::io(e.to_string()))?; + let meta = file + .metadata() + .map_err(|e| CommandError::io(e.to_string()))?; + if !meta.is_file() { + return Err(CommandError::invalid_input( + "Transcript must be a regular file", + )); + } + let start = meta.len().saturating_sub(limit); + let mut at_boundary = start == 0; + if start > 0 { + file.seek(SeekFrom::Start(start - 1)) + .map_err(|e| CommandError::io(e.to_string()))?; + let mut preceding = [0u8; 1]; + file.read_exact(&mut preceding) + .map_err(|e| CommandError::io(e.to_string()))?; + at_boundary = preceding[0] == b'\n'; + } + file.seek(SeekFrom::Start(start)) + .map_err(|e| CommandError::io(e.to_string()))?; + let mut bytes = Vec::new(); + file.take(limit) + .read_to_end(&mut bytes) + .map_err(|e| CommandError::io(e.to_string()))?; + if !at_boundary { + if let Some(i) = bytes.iter().position(|b| *b == b'\n') { + bytes.drain(..=i); + } else { + bytes.clear(); + } + } + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} diff --git a/src-tauri/src/terminal_transcript/tests.rs b/src-tauri/src/terminal_transcript/tests.rs new file mode 100644 index 000000000..8cf7b5fbc --- /dev/null +++ b/src-tauri/src/terminal_transcript/tests.rs @@ -0,0 +1,160 @@ +//! WI-TP1.1: preserve existing hooks; refuse arbitrary file reads. +use super::*; +#[test] +fn merges_hooks_without_destroying_settings() { + let mut config = serde_json::json!({"theme":"dark", "hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"existing"}]}]}}); + assert!(add_hook(&mut config, "node '/preview.cjs'").unwrap()); + assert!( + !add_hook(&mut config, "node '/preview.cjs'").unwrap(), + "second add is a no-op" + ); + assert_eq!(config["theme"], "dark"); + assert_eq!(config["hooks"]["SessionStart"].as_array().unwrap().len(), 2); + assert_eq!( + config["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "existing" + ); +} +#[test] +fn invalid_hook_config_is_not_overwritten() { + assert!(add_hook(&mut serde_json::json!({"hooks":false}), "test").is_err()); +} +#[test] +fn tokens_are_opaque_uuids() { + assert!(valid_token("cb28fc00-2c1b-4eaf-9d09-71d9d5392926")); + assert!(!valid_token("../secret")); + assert!(!valid_token("")); +} +#[test] +fn canonical_paths_are_confined_to_session_roots() { + let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&root).unwrap(); + let transcript = root.join("test.jsonl"); + std::fs::write(&transcript, b"{}\n").unwrap(); + assert!(allowed_path( + &transcript.canonicalize().unwrap(), + std::slice::from_ref(&root) + )); + assert!(!allowed_path( + Path::new("/etc/passwd"), + std::slice::from_ref(&root) + )); + assert!(!allowed_path( + &root.join("test.json"), + std::slice::from_ref(&root) + )); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn tail_reader_bounds_bytes_and_drops_split_records() { + let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("tail.jsonl"); + std::fs::write(&path, b"first\nsecond\npartial").unwrap(); + assert_eq!(read_tail(&path, 12).unwrap(), "partial"); + assert_eq!(read_tail(&path, 14).unwrap(), "second\npartial"); + assert_eq!(read_tail(&path, 100).unwrap(), "first\nsecond\npartial"); + std::fs::write(&path, b"truncated\n").unwrap(); + assert_eq!(read_tail(&path, 100).unwrap(), "truncated\n"); + std::fs::remove_dir_all(root).unwrap(); +} +fn snapshot_fixture() -> (PathBuf, PathBuf, String) { + let base = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&base).unwrap(); + // Canonical, so the macOS /var -> /private/var link does not defeat confinement. + let base = base.canonicalize().unwrap(); + let root = base.join("bindings"); + let sessions = base.join("sessions"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write(root.join("enabled"), b"enabled").unwrap(); + (root, sessions, uuid::Uuid::new_v4().to_string()) +} +fn bind(root: &Path, token: &str, transcript: &Path) { + let binding = serde_json::json!({"path": transcript, "sessionId": "s"}); + std::fs::write(root.join(format!("{token}.json")), binding.to_string()).unwrap(); +} +#[test] +fn snapshot_waits_until_enabled_bound_and_written() { + let (root, sessions, token) = snapshot_fixture(); + let roots = [sessions.clone()]; + assert!(read_snapshot(&root, &roots, &token, None) + .unwrap() + .is_none()); + // The CLI announces its transcript before creating it. + let transcript = sessions.join("t.jsonl"); + bind(&root, &token, &transcript); + assert!(read_snapshot(&root, &roots, &token, None) + .unwrap() + .is_none()); + std::fs::write(&transcript, b"{\"a\":1}\n").unwrap(); + let first = read_snapshot(&root, &roots, &token, None).unwrap().unwrap(); + assert_eq!(first.data.as_deref(), Some("{\"a\":1}\n")); + let same = read_snapshot(&root, &roots, &token, Some(&first.revision)) + .unwrap() + .unwrap(); + assert!(same.data.is_none(), "unchanged revision skips the read"); + std::fs::remove_file(root.join("enabled")).unwrap(); + assert!(read_snapshot(&root, &roots, &token, None) + .unwrap() + .is_none()); + std::fs::remove_dir_all(root.parent().unwrap()).unwrap(); +} +#[test] +fn snapshot_refuses_transcripts_outside_session_roots() { + let (root, sessions, token) = snapshot_fixture(); + let outside = root.parent().unwrap().join("secret.jsonl"); + std::fs::write(&outside, b"{}\n").unwrap(); + bind(&root, &token, &outside); + let err = read_snapshot(&root, &[sessions], &token, None) + .err() + .unwrap(); + assert!(format!("{err:?}").contains("outside")); + std::fs::remove_dir_all(root.parent().unwrap()).unwrap(); +} +#[test] +fn forgetting_and_disabling_remove_bindings_only() { + let (root, sessions, token) = snapshot_fixture(); + bind(&root, &token, &sessions.join("t.jsonl")); + remove_binding(&root, &token).unwrap(); + remove_binding(&root, &token).unwrap(); // idempotent + assert!(!root.join(format!("{token}.json")).exists()); + bind(&root, &token, &sessions.join("t.jsonl")); + std::fs::write(root.join("unrelated.json"), b"{}").unwrap(); + configure(&root, false, &root.join("claude"), &root.join("codex")).unwrap(); + assert!(!root.join(format!("{token}.json")).exists()); + assert!(!root.join("enabled").exists()); + assert!(root.join("unrelated.json").exists()); + std::fs::remove_dir_all(root.parent().unwrap()).unwrap(); +} +#[test] +fn enabling_leaves_already_configured_cli_files_untouched() { + let (root, _sessions, _token) = snapshot_fixture(); + let base = root.parent().unwrap().to_path_buf(); + let (claude, codex) = (base.join("claude"), base.join("codex")); + configure(&root, true, &claude, &codex).unwrap(); + let settings = claude.join("settings.json"); + let hooks = codex.join("hooks.json"); + assert!(std::fs::read_to_string(&settings) + .unwrap() + .contains("terminal-transcript-hook.cjs")); + // Rewrite both compactly: a second enable must not reformat (i.e. rewrite) them. + for path in [&settings, &hooks] { + let value: serde_json::Value = + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + std::fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap(); + } + let before = ( + std::fs::read(&settings).unwrap(), + std::fs::read(&hooks).unwrap(), + ); + configure(&root, true, &claude, &codex).unwrap(); + assert_eq!( + before, + ( + std::fs::read(&settings).unwrap(), + std::fs::read(&hooks).unwrap() + ) + ); + std::fs::remove_dir_all(base).unwrap(); +} diff --git a/src/components/Terminal/TerminalPanel.transcript.test.tsx b/src/components/Terminal/TerminalPanel.transcript.test.tsx new file mode 100644 index 000000000..78bfa70af --- /dev/null +++ b/src/components/Terminal/TerminalPanel.transcript.test.tsx @@ -0,0 +1,92 @@ +/** + * TerminalPanel — rendered-transcript wiring (WI-TP3.3). + * + * The audit flagged the path from `terminal.transcriptPreview` through hook + * configuration and following to the tab-bar toggle and the region as an + * untested critical path: each piece is tested alone, so broken wiring here + * would pass them all. The hooks are mocked at their boundary; the panel's + * job is only to connect them. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, fireEvent, screen } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + rendered: vi.fn(), + toggle: vi.fn(), + state: { expanded: false, failed: false, messages: [] as { id: string; text: string }[] }, +})); + +vi.mock("./useTerminalSessions", () => ({ + useTerminalSessions: () => ({ fit: vi.fn(), getActiveTerminal: () => null, getActiveSearchAddon: () => null, restartActiveSession: vi.fn() }), +})); +vi.mock("./useTerminalResize", () => ({ useTerminalResize: () => ({ isResizing: false, handleResizeStart: vi.fn() }) })); +vi.mock("./TerminalSearchBar", () => ({ TerminalSearchBar: () => null })); +vi.mock("@/plugins/mermaid", () => ({ renderMermaid: vi.fn().mockResolvedValue(null) })); +vi.mock("./useTranscriptConfiguration", () => ({ useTranscriptConfiguration: (enabled: boolean) => (enabled ? "ready" : "pending") })); +vi.mock("./useRenderedTranscript", () => ({ + useRenderedTranscript: (...args: unknown[]) => { + mocks.rendered(...args); + return { ...mocks.state, toggle: mocks.toggle }; + }, +})); +vi.mock("./TerminalTabBar", () => ({ + TerminalTabBar: (props: { transcript?: { expanded: boolean; controls: string; onToggle: () => void } }) => + props.transcript ? ( + + )} diff --git a/src/components/Terminal/TerminalTranscript.test.tsx b/src/components/Terminal/TerminalTranscript.test.tsx new file mode 100644 index 000000000..c218551d6 --- /dev/null +++ b/src/components/Terminal/TerminalTranscript.test.tsx @@ -0,0 +1,23 @@ +// WI-TP2.1 / WI-TP3.3: the rendered transcript region; its toggle lives in the tab bar. +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TerminalTranscript } from "./TerminalTranscript"; +vi.mock("@/plugins/mermaid", () => ({ renderMermaid: vi.fn().mockResolvedValue(null) })); +const TABLE = { id: "t", text: "| A | B |\n| --- | --- |\n| X | Y |" }; +describe("TerminalTranscript", () => { + it("is a named region the toggle can point at, rendering each reply", () => { + render(); + const region = screen.getByRole("region", { name: "Rendered Transcript" }); + expect(region).toHaveAttribute("id", "tx"); + expect(screen.getByRole("table")).toHaveTextContent("X"); + }); + it("waits without an error while hook configuration is still pending", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByText(/Waiting for a Claude\/Codex session/)).toBeInTheDocument(); + }); + it.each([["configuration", { failed: false, configuration: "failed" as const }], ["reading", { failed: true, configuration: "ready" as const }]])("reports a %s failure", (_label, state) => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Transcript unavailable"); + }); +}); diff --git a/src/components/Terminal/TerminalTranscript.tsx b/src/components/Terminal/TerminalTranscript.tsx new file mode 100644 index 000000000..71c6cd491 --- /dev/null +++ b/src/components/Terminal/TerminalTranscript.tsx @@ -0,0 +1,15 @@ +/** The rendered transcript region beside the CLI grid. Mounted only while open — + * its toggle lives in the tab bar and its state in useRenderedTranscript — so a + * collapsed transcript costs no space and renders no diagrams. + * @module components/Terminal/TerminalTranscript */ +import { useTranslation } from "react-i18next"; +import type { TranscriptMessage } from "@/utils/terminalTranscript"; +import { TranscriptMarkdown } from "./TranscriptMarkdown"; +import type { TranscriptConfigStatus } from "./useTranscriptConfiguration"; +import "./terminal-transcript.css"; +export function TerminalTranscript({ id, messages, failed, configuration }: { id: string; messages: TranscriptMessage[]; failed: boolean; configuration: TranscriptConfigStatus }) { + const { t } = useTranslation("settings"); + return
+ {configuration === "failed" || failed ?

{t("terminal.transcript.error")}

: messages.length ? messages.map(message =>
) :

{t("terminal.transcript.waiting")}

} +
; +} diff --git a/src/components/Terminal/TranscriptMarkdown.test.tsx b/src/components/Terminal/TranscriptMarkdown.test.tsx new file mode 100644 index 000000000..cffc79847 --- /dev/null +++ b/src/components/Terminal/TranscriptMarkdown.test.tsx @@ -0,0 +1,32 @@ +// WI-TP2.1: real tables, inert model-supplied markup, failure fallback. +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TranscriptMarkdown } from "./TranscriptMarkdown"; +vi.mock("@/plugins/mermaid", () => ({ renderMermaid: vi.fn().mockResolvedValue(null) })); +describe("TranscriptMarkdown", () => { + it("renders ordinary Markdown without using editor state", () => { + const { container } = render( quote\n\n1. ordered\n\n- unordered\n\n---\n\n```js\nconst x = 1;\n```\n\n[reference][r]\n\n[r]: https://example.com\n\n![alternate][i]\n\n[i]: https://example.com/image"} />); + expect(container.querySelector("em")).toHaveTextContent("emphasis"); + expect(container.querySelector("del")).toHaveTextContent("removed"); + expect(container.querySelector("blockquote")).toHaveTextContent("quote"); + expect(container.querySelector("ol")).toHaveTextContent("ordered"); + expect(container.querySelector("ul")).toHaveTextContent("unordered"); + expect(container.querySelector("hr")).not.toBeNull(); + expect(container.querySelector("br")).not.toBeNull(); + expect(container.querySelector("pre")).toHaveTextContent("const x = 1;"); + expect(container.querySelector("img, a")).toBeNull(); + }); + it("renders GFM tables with selectable text", () => { + render(); + expect(screen.getByRole("table")).toHaveTextContent("中文"); + expect(screen.getAllByRole("columnheader")).toHaveLength(2); + }); + it("does not activate raw HTML, external images or javascript links", () => { + const { container } = render(alert(1)\n\n![image](https://example.com/i.png)\n\n[link](javascript:alert(1))'} />); + expect(container.querySelector("script, img, a")).toBeNull(); + }); + it("keeps invalid Mermaid readable", async () => { + render(); + expect(await screen.findByText("invalid diagram")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Terminal/TranscriptMarkdown.tsx b/src/components/Terminal/TranscriptMarkdown.tsx new file mode 100644 index 000000000..c7984348b --- /dev/null +++ b/src/components/Terminal/TranscriptMarkdown.tsx @@ -0,0 +1,34 @@ +/** Read-only transcript Markdown: React elements, no raw HTML or remote resources. + * @module components/Terminal/TranscriptMarkdown */ +import { useMemo, type ReactNode } from "react"; +import type { RootContent, PhrasingContent } from "mdast"; +import { TranscriptMermaid } from "./TranscriptMermaid"; +import { parseTranscriptMarkdown } from "./transcriptMarkdownTree"; +type Node = RootContent | PhrasingContent; +function renderNode(node: Node, key: number): ReactNode { + const children = "children" in node ? node.children.map((child, index) => renderNode(child as Node, index)) : null; + switch (node.type) { + case "text": return node.value; + case "paragraph": return

{children}

; + case "heading": return {children}; + case "emphasis": return {children}; + case "strong": return {children}; + case "delete": return {children}; + case "inlineCode": return {node.value}; + case "code": return node.lang === "mermaid" ? :
{node.value}
; + case "blockquote": return
{children}
; + case "list": return node.ordered ?
    {children}
:
    {children}
; + case "listItem": return
  • {children}
  • ; + case "break": return
    ; + case "thematicBreak": return
    ; + case "table": return
    {node.children[0]?.children.map((cell, index) => )}{node.children.slice(1).map((row, index) => {row.children.map((cell, index) => )})}
    {cell.children.map(renderNode)}
    {cell.children.map(renderNode)}
    ; + case "link": case "linkReference": return {children}; + case "image": case "imageReference": return {node.alt}; + case "html": return
    {node.value}
    ; + default: return null; + } +} +export function TranscriptMarkdown({ text }: { text: string }) { + const nodes = useMemo(() => parseTranscriptMarkdown(text), [text]); + return
    {nodes ? nodes.map(renderNode) :
    {text}
    }
    ; +} diff --git a/src/components/Terminal/TranscriptMermaid.test.tsx b/src/components/Terminal/TranscriptMermaid.test.tsx new file mode 100644 index 000000000..232631f1a --- /dev/null +++ b/src/components/Terminal/TranscriptMermaid.test.tsx @@ -0,0 +1,15 @@ +// WI-TP2.1: diagram isolation and intrinsic sizing within the terminal. +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TranscriptMermaid } from "./TranscriptMermaid"; +vi.mock("@/plugins/mermaid", () => ({ renderMermaid: vi.fn().mockResolvedValue('Diagram') })); +describe("TranscriptMermaid", () => { + it("uses intrinsic aspect ratio and a scriptless sandbox", async () => { + render(); + const frame = await screen.findByTitle("Mermaid diagram"); + expect(frame).toHaveAttribute("sandbox", ""); + expect(frame).toHaveStyle({ aspectRatio: "6" }); + expect(frame.getAttribute("srcdoc")).not.toContain("(null); + useEffect(() => { + let cancelled = false; + void renderMermaid(source, `transcript-${id}`, true).then(svg => { + if (!cancelled && svg) { + const clean = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, FORBID_TAGS: ["script", "image", "a"] }); + const box = new DOMParser().parseFromString(clean, "image/svg+xml").documentElement.getAttribute("viewBox")?.trim().split(/[\s,]+/).map(Number); + const ratio = box && box.length === 4 && box[2] > 0 && box[3] > 0 && Number.isFinite(box[2] / box[3]) ? box[2] / box[3] : 2; + setRendered({ source, ratio, html: '' + clean }); + } + }); + return () => { cancelled = true; }; + }, [source, id]); + return rendered?.source === source ?