Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions scripts/feature-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 8 additions & 6 deletions scripts/lib/docJoins/settingsDefaults.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -100,6 +101,7 @@ const TERMINAL_DEFAULTS = {
fontSize: 13,
lineHeight: 1.2,
copyOnSelect: false,
transcriptPreview: false,
macOptionIsMeta: true,
shellIntegration: true,
osc52Clipboard: true,
Expand Down Expand Up @@ -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"',
]);
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});
Expand Down
2 changes: 2 additions & 0 deletions scripts/lib/docJoins/settingsDefaultsRowMap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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),
Expand Down
26 changes: 26 additions & 0 deletions scripts/terminal-transcript-hook.test.mjs
Original file line number Diff line number Diff line change
@@ -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 }); }
});
19 changes: 19 additions & 0 deletions src-tauri/resources/terminal-transcript-hook.cjs
Original file line number Diff line number Diff line change
@@ -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. */ }
});
4 changes: 4 additions & 0 deletions src-tauri/src/command_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
};
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -145,6 +146,8 @@ fn manage_state<R: tauri::Runtime>(builder: tauri::Builder<R>) -> 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.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
57 changes: 57 additions & 0 deletions src-tauri/src/terminal_transcript/config.rs
Original file line number Diff line number Diff line change
@@ -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<bool, CommandError> {
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())
})
}
Loading
Loading