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 ?