Clicking a file path in Claude's terminal output now opens the file in its own window with a CodeMirror 6 editor. The editor highlights the target line, live-reloads while the file changes, and saves explicitly with hash-based conflict detection. The viewer commands are gated by window label. Every app command is now ACL-gated per window through a Tauri AppManifest. build.rs checks the handler list against the capability files and fails the build on any mismatch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
30 KiB
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 };projectIdis derived from the session in the store):WebLinksAddon(http/https only, its ownregisterLinkProviderinside the addon), OSC 8 vialinkHandler(createOsc8LinkHandler(getHost, readState); xterm'sOscLinkProviderdrops any target that is nothttp:/https:or that failsnew URL()becauseallowNonHttpProtocolsis unset), OSC 7777 URL relay. Nothing callsterminal.registerLinkProviderdirectly.opensOnClick(event, ctx, modifierPromised)is a module-private function inTerminalView.tsx; toasts areuseAppState.getState().pushToast.read_container_file(project_id, path, max_bytes)incommands/file_commands.rsreads via bollarddownload_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 atMAX_READ_BYTES= 8 MiB, returnsFileContents { contents_base64, truncated, size }, and does not callrequire_running.validate_container_path(absolute, no NUL, no..segment, ≤ 4096 bytes; it rejects rather than normalises) andvalidate_container_write_path(that plusis_under_rootagainstCONTAINER_WRITE_ROOTS = /workspace, /home/claude, /tmp, compared by whole segments) are privatefns infile_commands.rs.- Execs: the container user is addressed by name,
"claude"(neveruid: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_idsis the host-file variant of the same tar path.resolve_container_dirrunsrealpath -masclaudeand re-validates the result against the write roots (allowing the literal path whenrealpathfails). FileViewerModal+components/projects/home/filePreview.ts(Files tab):previewKind,imageMimeFor,previewLimit,decodeBase64,looksBinary,TEXT_PREVIEW_LIMIT1 MiB,IMAGE_PREVIEW_LIMIT5 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 buildsWebviewWindowBuilder::new(app, &label, WebviewUrl::External(url))straight from an async command (norun_on_main_thread; Tauri documents that windows must be created from async commands, not sync ones), hookswindow.on_window_eventforDestroyed, and closes withdestroy(), neverclose().lib.rs'son_window_eventreturns early for any label butmain. build.rsis a baretauri_build::build(): every app command is callable from every local window.capabilities/default.jsongates plugin commands only and listswindows: ["main"]. App commands need no capability entry (CLAUDE.md, "Key Conventions"). (Historical snapshot at3537b23. 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/<mount_name>(ProjectPath { host_path, mount_name }, rows with an emptymount_nameare skipped at container creation)./workspaceitself is not a mount. - Tauri multi-window facts that the design rests on:
WebviewUrl::App("viewer.html".into())isUrl::joined ontobuild.devUrlin dev (http://localhost:1420/viewer.html) and ontotauri://localhost/in a bundle (http://tauri.localhost/on Windows); both areOrigin::Local, so capability files apply. Ifviewer.htmlis missing, both Vite's dev server and Tauri's asset lookup silently fall back toindex.html— the main app opens in the viewer window. A test guards against this.- Capability
windowsentries areglob::Patterns, so"file-viewer-*"matches. getCurrentWindow().onCloseRequested(cb)listens ontauri://close-requestedand then callsdestroy()itself; Rust callsprevent_close()whenever a JS listener exists. So the viewer needscore:window:allow-destroyor the X button stops working the moment the listener is registered.listen/unlistenneedcore:event:allow-listen/allow-unlisten. Rust→window emits need no grant on the receiving side.app.emit_to(label, …)targets one label, but a barelisten()in the main window (EventTarget::Any) still receives it. The viewer listens throughgetCurrentWindow().listen(...), and the main window never listens to viewer event names.- The
app.security.cspapplies to every.htmlTauri serves,viewer.htmlincluded. Tauri adds a'nonce-…'tostyle-srconly when the entry HTML contains a literal<style>element, and a nonce disables'unsafe-inline'— which CodeMirror'sstyle-modneeds for its runtime<style>injection.viewer.htmlmust 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[], whereFilePathMatch = { start, end, path, line?, col?, endLine? }(startinclusive,endexclusive, string indices intolineText), used by a newILinkProviderregistered withterminal.registerLinkProviderright afterterm.loadAddon(webLinksAddon)(providers are asked in registration order; the web-links one runs first, so anhttp://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-L50suffixes. - 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.comwith 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
isWrappedcontinuation rows the wayWebLinksAddon's privateLinkComputer._getWindowedLineStringsdoes (walk up while the rowisWrapped, walk down while the next rowisWrapped, 2048-char budget,translateToString (true)), then maps string indices back to{x, y}cells.LinkComputeris not exported from the built addon, so the walk is re-implemented inapp/src/lib/xtermLineJoin.ts(joinWrappedRows(buffer, rowIndex0) -> { text, firstRow0, rowStarts }plusstringIndexToCell).urlDetector.tsworks on the byte stream, not the buffer, so it has nothing reusable here.ILink.rangeis 1-based on both axes with an inclusive end column;provideLinks(bufferLineNumber)is 1-based andbuffer.active.getLine(y)is 0-based. - OSC 8
file://targets: setallowNonHttpProtocols: trueon the handler and dispatch by scheme increateOsc8LinkHandler. With the flag on, xterm hands every OSC 8 target toactivate/hover, including unparseable ones andjavascript:; so the handler parses withnew URL()itself:file:→onOpenFile(pathname decoded, no line),http(s):→ the existingsanitizeRelayUrl→openUrlExternalpath 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 afile:target shows the path and "Open in viewer". TheWebLinksAddoncomment that describes the oldallowNonHttpProtocolsbehaviour is updated, not left stale. - Activation uses the same click gating as web links (
opensOnClick: no selection drag, single click;modifierPromisedfor OSC 8 hovers). A click invokesopenFileViewer(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 apushToast({ 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-emptymount_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.
- Absolute: exactly one candidate, after
- Existence is checked in one exec as
claude:sh -cwith the candidates as$1..$n, printingrealpath -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_filerefuses a link, so the resolved path is the only one that reads). The resolved path is re-checked withvalidate_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, viaproject.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_viewercreates aWebviewWindowwith labelfile-viewer-<counter>(monotonicAtomicU64, never reused within a process), loading the app's own bundle at a second Vite entry (viewer.html→src/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 isasync(Tauri's requirement for window creation from a command). - A registry
ViewerRegistry { entries: Mutex<HashMap<String, ViewerTarget>>, next: AtomicU64 }managed withapp.manage(...)(separate fromAppState, like the browser view keeps its own state), whereViewerTarget { project_id, project_name, state: Resolved{container_path} | Choose{candidates} | NotFound{tried}, initial: Location { line, col, end_line } }. Entry removed onWindowEvent::Destroyed. - If a window for the same
(project_id, container_path)is already open (Resolved entries only):unminimize(),set_focus()andapp.emit_to(label, "file-viewer-goto", Location)to that window instead of opening a duplicate; the viewer listens withgetCurrentWindow().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, notcontainer_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
codemirrormeta-package, no@codemirror/language-data— it hard-depends on 13 more grammars, one of which,legacy-modes/mode/pug, callsFunction(...)):@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 viaStreamLanguage.define), plus@lezer/highlightfor the tag-based highlight style. Language packs are chosen by extension inviewer/languages.tsand loaded with dynamicimport()(same-origin chunks, fine underscript-src 'self'). Verified: none of these packages useseval/new Function/Workers;style-modinjects a<style>element, allowed bystyle-src 'unsafe-inline'. No CSP change. - Theme:
EditorView.theme+HighlightStyleinviewer/viewerTheme.ts, colours from the app's CSS custom properties (index.cssis imported by the viewer entry so the tokens exist). - Line numbers, search (Ctrl/Cmd+F,
searchKeymap), go to line (gotoLine), history,indentWithTab,lineWrappingfor markdown/plain text, highlight of the target line/range (aDecoration.lineStateFielddriven by asetHighlightStateEffect), scroll target into view centred (EditorView.scrollIntoView(pos, { y: "center" })) on open and onfile-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.tsclassification andlooksBinary), not truncated (≤ 1 MiB), and Rust sayseditable: true(the resolved path is insideCONTAINER_WRITE_ROOTS). Otherwise the editor is read-only (EditorState.readOnly+EditorView.editableboth false) with a badge stating why. Images render read-only as inFileViewerModal(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, andsha256suminside 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 (Resolvedonly).hashis over the returned bytes.editableisvalidate_container_write_path(resolved)succeeding;readonly_reasonis its message otherwise.viewer_poll_file()→ViewerPoll { exists, hash: Option<String>, size: Option<u64> }via one exec asclaude: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 onvisibilitychangeback to visible. The reducer tracksdiskHash(last known full-file hash; seeded from the read'shashwhentruncatedis false, otherwise from the first poll) andbaseHash(the hash the buffer was loaded from).- Poll hash ==
diskHash→ nothing. - Poll hash changed, editor clean →
viewer_read_fileagain, 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
baseHashto 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.
- Poll hash ==
- 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):- Rust:
Resolvedentry only; decode; refuse if!editable, if content > 1 MiB, or ifbase_hashis not 64 hex chars. ExecSessionManager::write_file_to_container(container_id, "triple-c-viewer-<uuid>", bytes)→/tmp/triple-c-viewer-<uuid>, owned by the container user, mode 0644.- One
shscript asclaude(exec_oneshot_streams_as), argstarget tmp base_hash:test -f targetelse exit 4 (gone);sha256sum target≠base_hash→ exit 3 (conflict); if the directory is writable:cp tmp dir/.<name>.triple-c-tmp,chmod --reference=target staged,mv -f staged target; elsecat tmp > target(in-place fallback for a writable file in a read-only directory);rm -f tmpin every branch (trap); printsha256sum targeton success. - Exit 3 →
Err("conflict: …")(the window shows the "Changed on disk" banner); exit 4 →Err("gone: …"); other non-zero → the clipped stderr.
- Rust:
- 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 viachmod --reference. - Symlinks: the registry already holds the
realpath -etarget (§2), andeditablewas 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.
- 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
- Closing with unsaved edits → the window's own confirm bar (Save / Discard / Cancel) driven by
onCloseRequested(event.preventDefault()when dirty); Discard callsdestroy().
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 foronCloseRequestedto be able to close the window at all — see "Current state"), andcore:webview:allow-internal-toggle-devtools(dev-only, as indefault.json). Noallow-close, noset-title/set-focus/is-minimized(title and focus are set from Rust; polling pauses onvisibilityState).default.jsonstayswindows: ["main"]. Update the threat-model census indefault.json's description to name the second file and why the viewer getsallow-destroy. - Viewer commands take
window: tauri::Windowand check the label:open_file_viewer—window.label() == "main"only.viewer_get_state,viewer_read_file,viewer_poll_file,viewer_write_file,viewer_choose_file—file-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/dangerouslySetInnerHTMLon file content. CSP unchanged. - Closed by the AppManifest follow-up (
2026-09-22-app-manifest-lockdown-design.md, implemented):build.rsnow declares a TauriAppManifestfromgenerate_handler!, so a compromised viewer window can invoke only the fiveallow-viewer-*app commands granted incapabilities/file-viewer.json, not any other app command. - 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:
filePathLinksmatcher (positive/negative table incl. markdown wrapping, URLs, versions,:l:c, ranges,#L),xtermLineJoinon a fake buffer, OSC 8 scheme dispatch (inTerminalView.test.tsx, which already testscreateOsc8LinkHandler), 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.htmlpresence + Vite input assertion, andevery_command_is_registered_exactly_once(existing) for the new commands. npx tsc --noEmit,npx vitest run,cargo test,cargo clippyclean.- 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:
- 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
/tmpwith the existingwrite_file_to_container(container-user-owned), then oneshscript asclaudedoes hash-check +chmod --reference+mv -fin 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 --referencewas dropped: a non-root exec cannot chown, so the spec now says the saved file is owned by the container user. - 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 fromsha256sumequals the Rust-side hash exactly when the read was untruncated, which is the only editable case; the reducer keepsdiskHashseparate frombaseHashso read-only (truncated) files still detect change without a reload loop. - Resolution stores the
realpath -eresult (§2).fetch_container_filerefuses 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..(matchingvalidate_container_path, which does not normalise) rather than resolving it. - Exact capability set (§6).
onCloseRequestedin @tauri-apps/api 2.11 callsthis.destroy(), and Rustprevent_close()s whenever a JS listener exists, socore:window:allow-destroyis mandatory;allow-closeis not needed and not granted. Focus/title/unminimize are done from Rust, so nocore:window:allow-set-*grants. - OSC 8 with
allowNonHttpProtocols: truereceives unparseable andjavascript:targets (§1). The handler now parses and dispatches itself, and refuses everything butfile:andhttp(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 theWebLinksAddonbranch is updated as part of the change. - Wrapped-row joining is re-implemented (§1).
WebLinksAddon'sLinkComputeris not exported from the built package andurlDetector.tsnever touches the buffer. - CodeMirror package list pinned (§4). Direct packages only;
@codemirror/language-dataand thecodemirrormeta-package are excluded (extra grammars, one withFunction(...)). A home-grown theme from CSS tokens rather thantheme-one-dark. viewer.htmlfallback trap (Current state, §8). Both Vite dev and Tauri's asset lookup fall back toindex.htmlwhen the entry is missing; a Rust test asserts the file and the Vite input entry exist.- 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-wideAtomicU64. - Event delivery (§3).
emit_to(label, …)still reaches a barelisten()in the main window, so the viewer subscribes viagetCurrentWindow().listenand 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, apath:lineand apath: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 (catit 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
/etcor afile:///etc/hostsOSC 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 withmacOptionClickForcesSelection, or "Not clickable while a program holds the mouse" on Mac without it (openHintLabel/fillFileCardinTerminalView.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.txtin a bash tab), open it in the viewer, make a small text edit, and Save. Thencat -A(orxxd) 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.