# Terminal file viewer/editor — design Date: 2026-09-22 Status: approved in conversation (user); reviewed against the code 2026-09-22 (see "Decisions made during review" at the end); plan at `docs/superpowers/plans/2026-09-22-terminal-file-viewer.md` ## Goal File locations Claude prints in a terminal tab (`src/foo.ts:42`, `/workspace/x/README.md`, `app/src/lib/urlRelay.ts:139-150`) become clickable. A click opens the file in its **own OS window**, scrolled to and highlighting the line/range, syntax-highlighted, live-reloading while Claude changes it, and **editable** so specs and similar files can be read, edited and saved from inside the app. Non-goals: tabs inside a viewer window, creating new files, diff/merge views, opening files in a host editor, locking the viewer window out of every other app command (that is the follow-up AppManifest spec, `2026-09-22-app-manifest-lockdown-design.md`). ## Current state (verified against the tree at `3537b23`) - Versions: tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0, wry 0.55, Vite 6.4.1, @xterm/xterm 5.5.0, @xterm/addon-web-links 0.12.0, React 19, bollard 0.18.1. - `TerminalView.tsx` (props `{ sessionId, active }`; `projectId` is derived from the session in the store): `WebLinksAddon` (http/https only, its own `registerLinkProvider` inside the addon), OSC 8 via `linkHandler` (`createOsc8LinkHandler(getHost, readState)`; xterm's `OscLinkProvider` drops any target that is not `http:`/`https:` **or that fails `new URL()`** because `allowNonHttpProtocols` is unset), OSC 7777 URL relay. Nothing calls `terminal.registerLinkProvider` directly. `opensOnClick(event, ctx, modifierPromised)` is a module-private function in `TerminalView.tsx`; toasts are `useAppState.getState().pushToast`. - `read_container_file(project_id, path, max_bytes)` in `commands/file_commands.rs` reads via bollard `download_from_container` (`fetch_container_file`). **The archive endpoint does not follow a final symlink and the helper refuses links** ("is a link — open its target instead"), so any path the viewer reads must already be resolved to a regular file. It caps at `MAX_READ_BYTES` = 8 MiB, returns `FileContents { contents_base64, truncated, size }`, and does **not** call `require_running`. `validate_container_path` (absolute, no NUL, no `..` segment, ≤ 4096 bytes; it rejects rather than normalises) and `validate_container_write_path` (that plus `is_under_root` against `CONTAINER_WRITE_ROOTS = /workspace, /home/claude, /tmp`, compared by whole segments) are private `fn`s in `file_commands.rs`. - Execs: the container user is addressed by **name**, `"claude"` (never `uid:gid`); `docker::exec::exec_oneshot_streams_as(container_id, "claude", cmd, env) -> (stdout, stderr, exit_code)` runs without stdin, cwd `/workspace`, 8 MiB output cap, lossy UTF-8. There is no helper that feeds bytes to an exec's stdin. `ExecSessionManager::write_file_to_container (container_id, file_name, bytes)` lands bytes at `/tmp/` owned by the container user (`container_user_ids`) with mode 0644 via the archive API. `upload_host_file_with_ids` is the host-file variant of the same tar path. `resolve_container_dir` runs `realpath -m` as `claude` and re-validates the result against the write roots (allowing the literal path when `realpath` fails). - `FileViewerModal` + `components/projects/home/filePreview.ts` (Files tab): `previewKind`, `imageMimeFor`, `previewLimit`, `decodeBase64`, `looksBinary`, `TEXT_PREVIEW_LIMIT` 1 MiB, `IMAGE_PREVIEW_LIMIT` 5 MiB. Reuse the classification and limits. - Only second window today is the browser-view pop-out (`browser_view/popout.rs`), a remote-origin window with no capability and no IPC. It builds `WebviewWindowBuilder::new(app, &label, WebviewUrl::External(url))` straight from an **async** command (no `run_on_main_thread`; Tauri documents that windows must be created from async commands, not sync ones), hooks `window.on_window_event` for `Destroyed`, and closes with `destroy()`, never `close()`. `lib.rs`'s `on_window_event` returns early for any label but `main`. - `build.rs` is a bare `tauri_build::build()`: **every app command is callable from every local window**. `capabilities/default.json` gates plugin commands only and lists `windows: ["main"]`. App commands need no capability entry (CLAUDE.md, "Key Conventions"). *(Historical snapshot at `3537b23`. Closed 2026-09-22 by the AppManifest lockdown (`2026-09-22-app-manifest-lockdown-design.md`) — see §6 below.)* - The frontend does not know a terminal's cwd. Terminal execs start in `/workspace`; each project path is bind-mounted at `/workspace/` (`ProjectPath { host_path, mount_name }`, rows with an empty `mount_name` are skipped at container creation). `/workspace` itself is not a mount. - Tauri multi-window facts that the design rests on: - `WebviewUrl::App("viewer.html".into())` is `Url::join`ed onto `build.devUrl` in dev (`http://localhost:1420/viewer.html`) and onto `tauri://localhost/` in a bundle (`http://tauri.localhost/` on Windows); both are `Origin::Local`, so capability files apply. **If `viewer.html` is missing, both Vite's dev server and Tauri's asset lookup silently fall back to `index.html`** — the main app opens in the viewer window. A test guards against this. - Capability `windows` entries are `glob::Pattern`s, so `"file-viewer-*"` matches. - `getCurrentWindow().onCloseRequested(cb)` listens on `tauri://close-requested` and then calls `destroy()` itself; Rust calls `prevent_close()` whenever a JS listener exists. So the viewer needs `core:window:allow-destroy` or **the X button stops working** the moment the listener is registered. `listen`/`unlisten` need `core:event:allow-listen`/`allow-unlisten`. Rust→window emits need no grant on the receiving side. - `app.emit_to(label, …)` targets one label, but a bare `listen()` in the main window (`EventTarget::Any`) still receives it. The viewer listens through `getCurrentWindow().listen(...)`, and the main window never listens to viewer event names. - The `app.security.csp` applies to every `.html` Tauri serves, `viewer.html` included. Tauri adds a `'nonce-…'` to `style-src` only when the entry HTML contains a literal `