Files
Triple-C/docs/superpowers/specs/2026-09-22-terminal-file-viewer-design.md
T
shadowdaoandClaude Opus 5.5 bf2291089a fix(viewer): final-review fixes — save base from written bytes, honest poll errors, retryable first read
- write.rs: a save's new base is sha256 of the bytes written; the script's
  post-mv hash comes back as disk_hash, and a mismatch (another writer landed
  after us) shows "Changed on disk" instead of being adopted (ledger M2).
- write.rs: conflict:/gone:/read-only strings are constants with a pure
  saved_file() mapping and tests; app/src/viewer/ipcMessages.ts is the one TS
  copy and a cargo test checks it against the Rust originals.
- write.rs: the comment now says the in-place `cat >` fallback follows a
  planted symlink, and why that is accepted (runs as claude).
- poll.rs: a file deleted between `test -f` and `sha256sum` reads as gone.
- viewerState/EditorPane: poll_failed carries its message; only the
  "Start the project before" refusal reads as Container not running, anything
  else gets its own banner and leaves Save enabled.
- EditorPane: a failed first read shows Retry and is retried by the poll.
- spec §1: refused OSC 8 targets keep the refusal card (Task 9 ruling).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:52:43 -07:00

30 KiB
Raw Blame History

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 fns 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/<file_name> 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").
  • The frontend does not know a terminal's cwd. Terminal execs start in /workspace; each project path is bind-mounted at /workspace/<mount_name> (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::joined 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::Patterns, 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 <style> element, and a nonce disables 'unsafe-inline' — which CodeMirror's style-mod needs for its runtime <style> injection. viewer.html must not contain an inline <style>.

Design

1. Path detection in the terminal (app/src/lib/filePathLinks.ts, TerminalView.tsx)

  • A pure matcher findFilePathLinks(lineText) -> FilePathMatch[], where FilePathMatch = { start, end, path, line?, col?, endLine? } (start inclusive, end exclusive, string indices into lineText), used by a new ILinkProvider registered with terminal.registerLinkProvider right after term.loadAddon(webLinksAddon) (providers are asked in registration order; the web-links one runs first, so an http:// span never reaches the file matcher as a candidate — the matcher additionally refuses any span overlapping ://).
  • Matches tokens that look like paths with a file extension (or a known extensionless name: Makefile, Dockerfile, CLAUDE.md-style names are already covered by the extension rule), optionally absolute, optionally .//../ prefixed, followed by optional :line, :line:col, or :start-end. Also accepts #L42 / #L40-L50 suffixes.
  • Strips wrapping that Claude's markdown rendering leaves: backticks, parentheses, brackets, quotes, trailing .,;: punctuation.
  • Must not match inside URLs (http links are already handled; skip spans overlapping ://), bare version numbers (1.2.3), or domain names (example.com with no / — require either a / in the token or an extension from an allowlist of common source/doc extensions for slash-less tokens).
  • Wrapped rows: v1 joins a buffer row with its isWrapped continuation rows the way WebLinksAddon's private LinkComputer._getWindowedLineStrings does (walk up while the row isWrapped, walk down while the next row isWrapped, 2048-char budget, translateToString (true)), then maps string indices back to {x, y} cells. LinkComputer is not exported from the built addon, so the walk is re-implemented in app/src/lib/xtermLineJoin.ts (joinWrappedRows(buffer, rowIndex0) -> { text, firstRow0, rowStarts } plus stringIndexToCell). urlDetector.ts works on the byte stream, not the buffer, so it has nothing reusable here. ILink.range is 1-based on both axes with an inclusive end column; provideLinks(bufferLineNumber) is 1-based and buffer.active.getLine(y) is 0-based.
  • OSC 8 file:// targets: set allowNonHttpProtocols: true on the handler and dispatch by scheme in createOsc8LinkHandler. With the flag on, xterm hands every OSC 8 target to activate/hover, including unparseable ones and javascript:; so the handler parses with new URL() itself: file:onOpenFile(pathname decoded, no line), http(s): → the existing sanitizeRelayUrlopenUrlExternal path unchanged, anything else → refused (console.warn, nothing opened). A refused target (javascript:, any other scheme, unparseable text) keeps the existing refusal card ("This link will not be opened — it failed the URL safety check"), which never echoes the target into the DOM; this is the ruled behaviour (Task 9), not a new hover card. The hover card for a file: target shows the path and "Open in viewer". The WebLinksAddon comment that describes the old allowNonHttpProtocols behaviour is updated, not left stale.
  • Activation uses the same click gating as web links (opensOnClick: no selection drag, single click; modifierPromised for OSC 8 hovers). A click invokes openFileViewer(projectId, rawPath, line, col, endLine)open_file_viewer. No confirmation toast — nothing leaves the app and nothing is written without an explicit save. A rejected open (cap, container not running, project gone) is a pushToast({ kind: "error" }).
  • Hover: underline + pointer cursor (decorations), and the same bottom-left card the OSC 8 handler draws, reading "Open in viewer".

2. Path resolution (Rust, commands/file_viewer_commands.rs + file_viewer/resolve.rs)

  • Pure candidate generation candidate_paths(raw, mount_names) -> Result<Vec<String>, String>:
    • Absolute: exactly one candidate, after validate_container_path (which rejects ..).
    • Relative p: strip a leading ./, collapse repeated /, reject any .. segment or NUL or length > 4096; candidates in order /workspace/<p>, then /workspace/<mount>/<p> for each non-empty mount_name, de-duplicated. Relative paths are the common case (Claude prints project-relative paths) and /workspace/<p> is first because the terminal's exec cwd is /workspace.
  • Existence is checked in one exec as claude: sh -c with the candidates as $1..$n, printing realpath -e -- "$c" for each candidate that is a regular file (test -f, which follows symlinks). Output lines are the resolved paths, which is what the registry stores (fetch_container_file refuses a link, so the resolved path is the only one that reads). The resolved path is re-checked with validate_container_path; a candidate whose resolution escapes to an invalid path is dropped. Candidate lists are capped at 16 entries; output lines are de-duplicated (two candidates may resolve to one file).
  • 0 matches → window opens in a "not found" state listing the candidates tried.
  • 1 match → open it.
  • >1 match → window opens in a "choose file" state listing matches; choosing one calls viewer_choose_file(index); the choice is from the Rust-held candidate list, never a path from the window.
  • Container must be running (require_running, via project.container_id); otherwise error toast in the main window, no window opened.

3. One window per click (src-tauri/src/file_viewer/{mod.rs,registry.rs,window.rs})

  • Each successful open_file_viewer creates a WebviewWindow with label file-viewer-<counter> (monotonic AtomicU64, never reused within a process), loading the app's own bundle at a second Vite entry (viewer.htmlsrc/viewer/main.tsx; build.rollupOptions.input = { main: "index.html", viewer: "viewer.html" }), title <basename> — <project name> (set from Rust), default 900×700, min 480×320, resizable. The command is async (Tauri's requirement for window creation from a command).
  • A registry ViewerRegistry { entries: Mutex<HashMap<String, ViewerTarget>>, next: AtomicU64 } managed with app.manage(...) (separate from AppState, like the browser view keeps its own state), where ViewerTarget { project_id, project_name, state: Resolved{container_path} | Choose{candidates} | NotFound{tried}, initial: Location { line, col, end_line } }. Entry removed on WindowEvent::Destroyed.
  • If a window for the same (project_id, container_path) is already open (Resolved entries only): unminimize(), set_focus() and app.emit_to(label, "file-viewer-goto", Location) to that window instead of opening a duplicate; the viewer listens with getCurrentWindow().listen("file-viewer-goto", ...).
  • Cap: 20 viewer windows (MAX_VIEWER_WINDOWS); the 21st click returns an error the main window shows as a toast. The cap counts registry entries, and an entry is reserved before the window is built so two concurrent clicks cannot both pass the check.
  • When the project's container stops/is removed, open viewer windows stay open but their reads fail and they show a "container not running" banner; they are not force-closed (unsaved text must not be destroyed). The registry stores project_id, not container_id, so a recreated container is picked up on the next poll.
  • lib.rs's shutdown teardown does nothing for viewers: the process exits and the OS closes them. Unsaved edits in a viewer are lost on app quit (accepted; it is what the main window's close does to a terminal too).

4. Editor (app/src/viewer/)

  • CodeMirror 6, direct packages only (no codemirror meta-package, no @codemirror/language-data — it hard-depends on 13 more grammars, one of which, legacy-modes/mode/pug, calls Function(...)): @codemirror/state, view, commands, search, language, lang-markdown, lang-javascript, lang-rust, lang-python, lang-json, lang-yaml, lang-css, lang-html, legacy-modes (toml, shell via StreamLanguage.define), plus @lezer/highlight for the tag-based highlight style. Language packs are chosen by extension in viewer/languages.ts and loaded with dynamic import() (same-origin chunks, fine under script-src 'self'). Verified: none of these packages uses eval/new Function/Workers; style-mod injects a <style> element, allowed by style-src 'unsafe-inline'. No CSP change.
  • Theme: EditorView.theme + HighlightStyle in viewer/viewerTheme.ts, colours from the app's CSS custom properties (index.css is imported by the viewer entry so the tokens exist).
  • Line numbers, search (Ctrl/Cmd+F, searchKeymap), go to line (gotoLine), history, indentWithTab, lineWrapping for markdown/plain text, highlight of the target line/range (a Decoration.line StateField driven by a setHighlight StateEffect), scroll target into view centred (EditorView.scrollIntoView(pos, { y: "center" })) on open and on file-viewer-goto.
  • Header: container path, project name, state badges (Read-only reason / Unsaved / Saved / Changed on disk / Container not running), Save button.
  • Editable only when: text (per filePreview.ts classification and looksBinary), not truncated (≤ 1 MiB), and Rust says editable: true (the resolved path is inside CONTAINER_WRITE_ROOTS). Otherwise the editor is read-only (EditorState.readOnly + EditorView.editable both false) with a badge stating why. Images render read-only as in FileViewerModal (Blob + object URL, IMAGE_PREVIEW_LIMIT).

5. Save + live reload

  • Content identity: SHA-256 hex of file bytes. Two producers, one definition — the hash of the file's full bytes: Rust computes it (sha2, already a dependency) over the bytes it fetched, and sha256sum inside the container computes it for polls and for the save check. The two agree exactly when the read was not truncated, which is the only case in which the hash is used as a save base.
  • viewer_read_file(max_bytes)ViewerFile { contents_base64, truncated, size, hash, editable, readonly_reason: Option<String> } for the caller's own target (Resolved only). hash is over the returned bytes. editable is validate_container_write_path(resolved) succeeding; readonly_reason is its message otherwise.
  • viewer_poll_file()ViewerPoll { exists, hash: Option<String>, size: Option<u64> } via one exec as claude: sha256sum -- "$1" + stat -c %s -- "$1". Missing file → exists: false. This is one small exec per window per tick, not a 1 MiB archive download.
  • Poll every 2 s while document.visibilityState === "visible"; pause otherwise; poll once immediately on visibilitychange back to visible. The reducer tracks diskHash (last known full-file hash; seeded from the read's hash when truncated is false, otherwise from the first poll) and baseHash (the hash the buffer was loaded from).
    • Poll hash == diskHash → nothing.
    • Poll hash changed, editor clean → viewer_read_file again, replace document, preserve scroll position and cursor (clamped), brief "Reloaded" indicator.
    • Poll hash changed, editor dirty → banner "Changed on disk" with Reload (discard mine) and Overwrite on save. No auto-merge. "Overwrite on save" sets baseHash to the polled hash so the next save succeeds.
    • exists: false → banner "File no longer exists"; content kept, editable text retained so the user can copy it; saving is disabled (no file creation).
    • Poll error (container not running) → banner "Container not running", editor keeps its state, polling continues so the banner clears when the container is back.
  • Save: Ctrl/Cmd+S or Save button → viewer_write_file(contents_base64, base_hash)Ok(hash).
    • Mechanism (all mutation of the target directory happens as the container user; the Docker archive API, which writes as root, only ever lands the payload in /tmp):
      1. Rust: Resolved entry only; decode; refuse if !editable, if content > 1 MiB, or if base_hash is not 64 hex chars.
      2. ExecSessionManager::write_file_to_container(container_id, "triple-c-viewer-<uuid>", bytes)/tmp/triple-c-viewer-<uuid>, owned by the container user, mode 0644.
      3. One sh script as claude (exec_oneshot_streams_as), args target tmp base_hash: test -f target else exit 4 (gone); sha256sum targetbase_hash → exit 3 (conflict); if the directory is writable: cp tmp dir/.<name>.triple-c-tmp, chmod --reference=target staged, mv -f staged target; else cat tmp > target (in-place fallback for a writable file in a read-only directory); rm -f tmp in every branch (trap); print sha256sum target on success.
      4. Exit 3 → Err("conflict: …") (the window shows the "Changed on disk" banner); exit 4 → Err("gone: …"); other non-zero → the clipped stderr.
    • Ownership: a non-root user cannot chown, so the file ends up owned by the container user — the same thing Claude Code's own edits produce. Mode is preserved via chmod --reference.
    • Symlinks: the registry already holds the realpath -e target (§2), and editable was computed on it, so a link into a non-writable root is read-only.
    • Returns the new hash; editor becomes clean, baseHash = diskHash = new hash.
  • Closing with unsaved edits → the window's own confirm bar (Save / Discard / Cancel) driven by onCloseRequested (event.preventDefault() when dirty); Discard calls destroy().

6. Security

  • New capability file capabilities/file-viewer.json, windows: ["file-viewer-*"], granting exactly: core:event:allow-listen, core:event:allow-unlisten, core:window:allow-destroy (required for onCloseRequested to be able to close the window at all — see "Current state"), and core:webview:allow-internal-toggle-devtools (dev-only, as in default.json). No allow-close, no set-title/set-focus/is-minimized (title and focus are set from Rust; polling pauses on visibilityState). default.json stays windows: ["main"]. Update the threat-model census in default.json's description to name the second file and why the viewer gets allow-destroy.
  • Viewer commands take window: tauri::Window and check the label:
    • open_file_viewerwindow.label() == "main" only.
    • viewer_get_state, viewer_read_file, viewer_poll_file, viewer_write_file, viewer_choose_filefile-viewer-* only, and they operate solely on the registry entry for the caller's own label. No viewer command accepts a path or a label as an argument.
  • Rendering: CodeMirror renders text as DOM text nodes; nothing uses innerHTML / dangerouslySetInnerHTML on file content. CSP unchanged.
  • Accepted residual risk (closed by the AppManifest follow-up): a compromised viewer window can still invoke other app commands, because build.rs does not restrict app commands per window.
  • Update CLAUDE.md (frontend/backend structure, Key Conventions note about the viewer window and label-gated commands).

7. Error handling

All Rust errors are sentences suitable for display. Container-authored text is clipped via clip_container_text (made pub(crate)). The main window shows open_file_viewer failures as toasts; the viewer shows read/write failures as banners and never loses the editor buffer on failure.

8. Testing

  • Vitest: filePathLinks matcher (positive/negative table incl. markdown wrapping, URLs, versions, :l:c, ranges, #L), xtermLineJoin on a fake buffer, OSC 8 scheme dispatch (in TerminalView.test.tsx, which already tests createOsc8LinkHandler), the viewer reload/dirty/conflict state machine as a pure reducer (viewer/viewerState.ts), editability rules, language selection.
  • Rust unit tests: candidate generation and normalization, label gating, registry dedupe/cap/removal, write script argument shape, size cap, viewer.html presence + Vite input assertion, and every_command_is_registered_exactly_once (existing) for the new commands.
  • npx tsc --noEmit, npx vitest run, cargo test, cargo clippy clean.
  • Manual (npx tauri dev): click paths in Claude output, multiple windows, edit + save, edit while Claude edits the same file (conflict banner), container stop with window open.

Decisions made during review

Facts were checked against tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0 sources, xterm 5.5.0, Vite 6.4.1 and the tree at 3537b23. The user-approved decisions (one window per click, editable, CodeMirror 6, probe-roots resolution, 2 s polling, no autosave, conflict banner, 20-window cap, AppManifest deferred) are unchanged. What changed:

  1. Write mechanism chosen (§5). No exec helper feeds stdin, and whether a half-close of the hijacked exec connection reaches the process is unverified. Instead: stage bytes at /tmp with the existing write_file_to_container (container-user-owned), then one sh script as claude does hash-check + chmod --reference + mv -f in the target directory. Every write into the target directory is therefore subject to the container user's permissions; the root-privileged archive API never touches it. chown --reference was dropped: a non-root exec cannot chown, so the spec now says the saved file is owned by the container user.
  2. Polling is an exec (sha256sum + stat), not a re-download (§5). A 2 s tick per window that streams a 1 MiB archive was the alternative. The full-file hash from sha256sum equals the Rust-side hash exactly when the read was untruncated, which is the only editable case; the reducer keeps diskHash separate from baseHash so read-only (truncated) files still detect change without a reload loop.
  3. Resolution stores the realpath -e result (§2). fetch_container_file refuses a symlink, so the raw candidate path could not be read; probing already needs an exec, so it resolves in the same one. Candidate normalisation rejects .. (matching validate_container_path, which does not normalise) rather than resolving it.
  4. Exact capability set (§6). onCloseRequested in @tauri-apps/api 2.11 calls this.destroy(), and Rust prevent_close()s whenever a JS listener exists, so core:window:allow-destroy is mandatory; allow-close is not needed and not granted. Focus/title/unminimize are done from Rust, so no core:window:allow-set-* grants.
  5. OSC 8 with allowNonHttpProtocols: true receives unparseable and javascript: targets (§1). The handler now parses and dispatches itself, and refuses everything but file: and http(s): before drawing a file or web hover card; a refused target gets only the existing refusal card, which never echoes the target. The stale comment in the WebLinksAddon branch is updated as part of the change.
  6. Wrapped-row joining is re-implemented (§1). WebLinksAddon's LinkComputer is not exported from the built package and urlDetector.ts never touches the buffer.
  7. CodeMirror package list pinned (§4). Direct packages only; @codemirror/language-data and the codemirror meta-package are excluded (extra grammars, one with Function(...)). A home-grown theme from CSS tokens rather than theme-one-dark.
  8. viewer.html fallback trap (Current state, §8). Both Vite dev and Tauri's asset lookup fall back to index.html when the entry is missing; a Rust test asserts the file and the Vite input entry exist.
  9. Registry is its own managed state (§3) rather than a field on AppState, with a reserve-before-build cap check; label counter is a process-wide AtomicU64.
  10. Event delivery (§3). emit_to(label, …) still reaches a bare listen() in the main window, so the viewer subscribes via getCurrentWindow().listen and the main window has no listener for viewer event names.

Manual verification checklist

Not runnable inside the planning/implementation container: it needs npx tauri dev on a machine with a display, plus a running project container. Run this after every task on this feature has landed, including any review fix rounds (in particular Task 11's CRLF/BOM save fix, item 16 below).

  • 1. In a Claude session, ask for a file listing; click src/… paths, /workspace/... absolute paths, a path:line and a path:start-end. Each opens its own window titled <basename> — <project>, scrolled to and highlighting the line/range.
  • 2. Click the same path again → the existing window is focused and re-highlights; no duplicate.
  • 3. Open 20 windows; the 21st click shows the "20 file windows are already open" toast.
  • 4. Edit, Ctrl+S: file changes in the container (cat it in a bash tab); mode preserved (stat -c %a); owner is the container user.
  • 5. While a window is open, have Claude edit the file: clean window reloads with "Reloaded"; a dirty window shows "Changed on disk" with both buttons; "Overwrite on save" then Save succeeds; "Reload (discard mine)" drops the edits.
  • 6. Save while the file changed between polls → "Changed on disk" banner, no data written.
  • 7. Delete the file in a bash tab → "File no longer exists", text still copyable, Save disabled.
  • 8. Stop the project with a window open → "Container not running" banner; start it → banner clears, polling resumes.
  • 9. Click a path under /etc or a file:///etc/hosts OSC 8 link → read-only badge with the write-roots reason.
  • 10. Close a dirty window with the X → Save / Discard / Cancel bar; Cancel keeps it open; Discard closes.
  • 11. Close the main window with viewers open → app exits, viewers close.
  • 12. Ctrl/Cmd+Shift+I opens devtools in a viewer in dev; in a release build the CSP console shows no violations (CodeMirror styles apply).
  • 13. A wrapped long path (narrow the terminal) underlines across the wrap and opens.
  • 14. Image (.png) opens read-only; a .bin/binary shows "not text".
  • 15. File-path hover key-hint wording. Hover a plain-text file-path link in the terminal (not an OSC 8 hyperlink) while a foreground program is holding the mouse (e.g. an interactive TUI like vim/htop/claude's own REPL) and confirm the hover card's key hint reads correctly for the platform/mode: "Shift+click to open" on non-Mac while the program tracks the mouse, "Click to open" when nothing tracks the mouse, "Option+click to open" on Mac with macOptionClickForcesSelection, or "Not clickable while a program holds the mouse" on Mac without it (openHintLabel / fillFileCard in TerminalView.tsx). Then confirm the click itself is held to whatever the card promised — the modifier actually required to activate the link matches the hint shown, even if the program changes its mouse-tracking mode between the hover and the click.
  • 16. CRLF/BOM file round-trip save. Open a file in the container that has Windows line endings (CRLF) and/or a UTF-8 BOM (e.g. printf '\xEF\xBB\xBF\r\nfoo\r\nbar\r\n' > /workspace/<project>/crlf.txt in a bash tab), open it in the viewer, make a small text edit, and Save. Then cat -A (or xxd) the file in the container and confirm the CRLF line endings and the BOM are still present/unchanged apart from the edit — i.e. the save did not silently normalize them to LF or strip the BOM.