Brief. Build the most beautiful, fast, read-only IDE-style code viewer. It browses directories and opens text files and images. It must include a real terminal so people can run their favourite CLI harness (Claude Code, Codex, aider, vim, htop…) next to the code. Use Bun + TypeScript only, with zero third-party dependencies, and ship binaries for macOS and Linux.
Six panelists reviewed the brief. Each section below gives a panelist's position and the decision the panel made. The final architecture is at the end.
Position. People will spend most of their time in the terminal with an agent while it edits code. The viewer is where they watch that work. So it has to:
- Show changes live. When a file changes on disk, the open tab reloads in place and keeps its scroll position. The changed lines get a short highlight. Tree entries that changed get a dot.
- Make the terminal a first-class panel, not an afterthought. It can sit at the bottom or on the right (for wide screens and agent workflows), can be maximized, and can hold several sessions as tabs.
- Connect the terminal and the viewer. ⌘/Ctrl-click a
path/to/file.ts:42in the terminal to open it at that line. Runningcodeviewer open <file>[:line]inside the built-in terminal opens the file in the viewer, so an agent can show you what it means. - Be keyboard-first. ⌘P quick-open (fuzzy), ⌘F find in file, ⌘G go to line, ⌃` toggle terminal, ⌘B toggle sidebar, ⌘W close tab, ⌘⇧[ / ⌘⇧] switch tabs.
Decision. All of the above is in v1. Out of scope: editing (it's read-only by design), git blame, LSP.
Position. "Beautiful" means calm, dense and precise, not decoration.
- One carefully tuned dark theme ("Midnight") and one light theme ("Daylight"), chosen
from
prefers-color-schemeand switchable. All colors are CSS custom properties, so the terminal's ANSI palette, the syntax colors and the chrome come from one set of tokens. - Use system UI fonts and system monospace fonts (SF Mono → JetBrains Mono → Cascadia → Menlo → DejaVu). No web fonts, so there's no network request and no flash of unstyled text.
- Use thin 1px hairlines, not heavy borders. Use 6px radii. Accent color only for focus and the active tab. File icons are hand-drawn compact SVG glyphs, tinted by language.
- Draw box-drawing and block characters in the terminal procedurally. Font glyphs leave gaps between cells, and that's what makes most web terminals look cheap. Rounded corners (╭╮╰╯), which agent TUIs use heavily, must look exact.
- Images get a checkerboard backdrop, fit-to-view / 1:1 / zoom, and a pixel-dimension readout.
Decision. Adopted. The terminal palette comes from theme tokens so the agent's colors match the chrome.
Position. Performance budgets: open a 100k-line file in under 100ms, keep scrolling at 60fps, keep the terminal smooth while it streams megabytes of output, and handle a repo of 200k files.
- Code view: virtualized. Only the visible lines (plus some overscan) are in the DOM, inside a fixed-height row model. Syntax highlighting is an incremental, state-carrying line tokenizer: the lexer state at the start of each line is cached, so highlighting line N costs O(lines since the last checkpoint), and only visible lines get tokenized. Files over 5 MB, or with lines over 20k characters, fall back to plain text.
- Tree: lazy-loads one directory at a time. The tree is flattened into a visible-rows array and rendered with the same virtualizer, so a folder with 50k entries stays fast.
- Quick open: the server walks the tree once in the background (skipping
.git,node_modules, and other heavy directories, with a 200k-entry cap) and keeps it current from the watcher. Fuzzy scoring runs on the client against the cached list. - Terminal: renders to a Canvas 2D context with dirty-row tracking and at most one
frame per
requestAnimationFrame. Text is drawn in runs of matching attributes, not cell by cell. PTY output arrives as binary WebSocket frames, gets parsed immediately, and is painted once per frame. The parser is a table-free switch state machine over code points with no regular expressions on the hot path. - Server: serves embedded, pre-gzipped assets from memory, and serves raw files with
Bun.file(zero-copysendfile).
Decision. Adopted, with the budgets above.
Position. Agent CLIs (Ink/React TUIs) and full-screen apps rely on a specific subset of xterm. If any of it is missing, the UI breaks in subtle ways. Required:
- PTY: use Bun's native
Bun.spawn({ terminal }), a real pseudo-terminal withresize(). This makesisatty, job control,SIGWINCHand Ctrl-C work properly. Without it nothing works, and Bun ≥ 1.3.5 provides it, so nonode-ptyis needed. - Parser: VT500-style states (ground / escape / CSI / OSC / DCS-passthrough /
string-ignore), streaming UTF-8 (
TextDecoderwithstream: true), C0/C1 controls. - Screen model: primary + alternate screen (1049/1047/47), a scroll region (DECSTBM), scrollback (10k lines, configurable), autowrap with the pending-wrap flag (DECAWM semantics), origin mode, tab stops, insert mode, DECSC/DECRC, and reflow-free resize that keeps the cursor.
- SGR: bold, dim, italic, underline (plus curly/double, rendered as underline),
blink (ignored), inverse, hidden, strikethrough, overline, 16/256/truecolor in both the
;and:forms. - Modes: DECCKM, bracketed paste (2004), focus events (1004), mouse 1000/1002/1003 + SGR 1006, cursor visibility (25), DECSCUSR cursor shapes, synchronized output (2026, paints deferred until the end marker).
- Reports: DA1/DA2, DSR 5/6 (cursor position), OSC 10/11/4 color queries (agents use these to pick a light or dark palette), DECRQM for 2026, XTVERSION.
- OSC: 0/2 title (shown on the tab), OSC 8 hyperlinks (clickable), OSC 52 clipboard write (agents use it for "copy to clipboard"), OSC 7 cwd.
- Width: an East-Asian-wide and emoji width table (wcwidth-compatible), combining marks attached to the previous cell.
- Input: a hidden
<textarea>captures keys, IME composition and paste. ⌥ acts as Meta on macOS. Shift+Enter sendsESC CR(the newline binding agents understand). Selection is by mouse drag, double-click selects a word and triple-click selects a line. ⌘C/⌘V on macOS, Ctrl+Shift+C/V on Linux.
Decision. Adopted in full. Kitty keyboard protocol and sixel/kitty graphics are deferred: the app answers the kitty query with nothing, so clients fall back cleanly.
Position. This app serves a shell over HTTP. Without care, any website the user
visits could open ws://127.0.0.1:PORT/pty and run commands (cross-site WebSocket
hijacking). Required:
- Bind to
127.0.0.1by default.--hostis an explicit opt-in and prints a warning. - Generate a 256-bit random session token at startup. The launch URL carries it in the
fragment (
/#t=…), which browsers never send to servers. The page moves it intosessionStorage(isolated per origin, i.e. per port) and sends it on every request as anx-cv-tokenheader; WebSocket upgrades carry it as a query parameter. No cookies: cookies are shared by every port on 127.0.0.1, so a cookie would leak to any other local server. (Revised after the round-3 security review; the first design used a cookie.) - Validate the
Hostheader on every request (127.0.0.1/localhost/[::1] plus our port), which defeats DNS rebinding. Writes must beapplication/json, which no cross-site form can send. - Never put the token in a process's arguments (
psand/procare readable by every user). Browsers are launched through a 0600 bootstrap page in a 0700 directory; macOS opens the default browser viaosascriptreading from stdin. Terminals get a separate token (CODEVIEWER_TOKEN) that only/api/openhonours, so no process in a shell holds the session token. - Check the Origin on WebSocket upgrades: it must match the (validated) server origin.
- Contain file access to the root. Resolve every path with
realpathand check that it stays under the root's realpath, so symlinks can't escape. Reject.., NUL bytes and absolute paths. The viewer is read-only: no write endpoints exist at all. - Serve all responses with a strict CSP (
default-src 'self', no inline script), plusX-Content-Type-Options: nosniff. SVGs are shown through<img>, never inlined, so scripts inside them can't run.
Decision. Adopted. These rules are non-negotiable.
Position. Users should get one self-contained binary with no runtime to install.
bun build --compileproduces a single executable that embeds the client bundle, which is minified and pre-gzipped at build time.- Targets:
darwin-arm64,darwin-x64,linux-x64,linux-arm64(glibc), pluslinux-x64-muslfor Alpine. - At launch the binary opens a chromeless app window when a Chromium-family browser is
installed (
--app=URL, which feels native, with no tabs or URL bar). Otherwise it falls back toopen/xdg-open.--no-openis for headless/SSH use. - The CLI is
codeviewer [dir] [--port N] [--host H] [--no-open] [--shell PATH], pluscodeviewer open <file[:line]>, which talks to the running instance through env vars injected into its terminals.
Decision. Adopted.
┌──────────────────────── single binary (bun --compile) ────────────────────────┐
│ server/ │
│ main.ts CLI args, `open` subcommand, browser launch │
│ http.ts Bun.serve: auth, CSP, static (embedded+gzip), REST, WebSockets │
│ files.ts safe path resolution, listing, text/binary sniffing, MIME │
│ index.ts background file index for quick-open │
│ search.ts query compiler, matcher, candidate list; search.worker.ts scans │
│ files off the main thread (killable on timeout) │
│ watch.ts recursive fs.watch → debounced change events │
│ pty.ts Bun.spawn({terminal}) sessions ↔ WebSocket │
├────────────────────────────────── client/ (vanilla TS, bundled) ──────────────┤
│ app.ts layout, splitters, keyboard, state │
│ tree.ts lazy virtualized file tree │
│ editor.ts tabs; dispatches to code / image / binary views │
│ codeview.ts virtualized lines, gutter, find, go-to-line, change flash │
│ highlight.ts incremental multi-language tokenizer │
│ palette.ts fuzzy quick-open │
│ search.ts sidebar search view: streamed, grouped results │
│ keys.ts pure shortcut and side-view helpers │
│ term/ terminal.ts (parser+screen), renderer.ts (canvas), │
│ input.ts (keys/mouse), width.ts, boxdraw.ts │
└───────────────────────────────────────────────────────────────────────────────┘
REST: GET /api/info · /api/list?path · /api/file?path · /api/raw?path · /api/index · /api/search?q (NDJSON)
WS: /ws/events (fs changes, open-file requests)
/ws/pty?id (binary: pty bytes; text JSON: resize/input control)
Two follow-up questions went to the panel: which fonts and sizes should be the defaults, and how should codeviewer run as a self-contained desktop app on the fastest, most secure open-source browser engine. Three specialists joined: a type designer, a reading-ergonomics and accessibility specialist, and a browser-security engineer.
The problem with round 1. System font stacks (SF Mono → Menlo → DejaVu…) give a different
face, width and x-height on every machine. Web pages can't use SF Mono by name, so macOS
actually fell back to Menlo. The terminal grid, the cell aspect ratio and how the code reads
changed with the host. A viewer whose job is reading needs a known, tuned typeface.
Candidates considered (all OFL / open source): JetBrains Mono, Iosevka, Fira Code, Cascadia Code, IBM Plex Mono, Source Code Pro, Commit Mono, Geist Mono.
- Type designer: For code, choose JetBrains Mono. It has a tall x-height, so it stays
legible at 12–14px. It has rectangular, open counters, and it clearly separates
0/O,1/l/Iandrn/m. It covers box-drawing and block elements, which matters for terminal fallbacks. Use the static, hinted instances (Regular, Bold, Italic, Bold Italic) rather than the variable font, because the hinted instances are crisper on 1× Linux displays. Iosevka is too narrow for long reading sessions. Fira Code and Cascadia are designed around ligatures. Plex Mono is beautiful but has a lower x-height and reads small. - Type designer: For UI, choose Inter (variable). It was designed for screens at 11–14px,
and it has optional glyphs that matter in a file explorer:
cv05(l with tail) andcv08(I with serifs) makeIl1unambiguous in file names. Use tabular figures (tnum) wherever numbers line up: the gutter and the status bar. - Ergonomics specialist: Ligatures off, everywhere. A read-only viewer must show the
exact characters on disk, and
!==must not turn into a glyph. Code needs more leading than an editor because people scan it rather than type into it, so use line-height 1.6. The terminal needs the opposite: TUIs are laid out for about 1.2, and box-drawing is drawn procedurally, so tight leading doesn't leave gaps. - Ergonomics specialist: Base the size on pixel density, not OS. Use 13px on HiDPI screens (device pixel ratio ≥ 2), where JetBrains Mono is designed to sit, and 14px on 1× screens, where 13px loses stem contrast. UI text is 13px, and section labels are 11px with +0.08em tracking.
- Terminal renderer engineer: The terminal must block until the fonts are loaded. The
cell grid is measured once, and measuring a fallback face gives wrong cell sizes.
font-display: blockapplies to the mono face andswapto Inter. Glyphs that JetBrains Mono lacks (CJK, emoji) still fall back to system fonts, as before.
Decision: Embed JetBrains Mono 2.304 (4 static WOFF2s, about 380 KB) and Inter 4.1 (variable WOFF2, 352 KB) in the binary, with their OFL licenses. No network access, and every machine renders the same way.
| Surface | Face | Size | Line height |
|---|---|---|---|
| UI chrome | Inter, cv05 cv08 |
13px (labels 11px) | 1.4 |
| Code | JetBrains Mono, no ligatures | 13px HiDPI / 14px 1× | 1.6 |
| Terminal | JetBrains Mono | 13px HiDPI / 14px 1× | 1.2 |
Requirement: codeviewer is self-contained and runs as its own app, on the fastest and most secure open-source browser engine. It depends on nothing installed on the machine.
| Option | Verdict |
|---|---|
| System WebView (WKWebView / WebKitGTK) | Not self-contained on Linux, and the engine differs per OS. Rejected. |
| Electron / Tauri | Third-party frameworks, which violates the zero-dependency rule. Rejected. |
| Firefox (Gecko) | Open source and solid, but it has no chromeless app-window mode (only kiosk), and its sandbox and site isolation are behind Chromium's. Rejected. |
| Chromium snapshots | Tip-of-tree development builds, not stable releases. Rejected. |
| ungoogled-chromium (stable) | Chromium's engine (V8, Blink, site isolation, seccomp-bpf and Seatbelt sandboxes) with Google services and telemetry removed. Current stable builds exist for macOS arm64/x64 (notarized with a Developer ID) and Linux x64/arm64. Each asset has a SHA-256 digest. Chosen. |
Hardening (browser-security engineer):
- A throwaway profile per launch, deleted on exit. Nothing persists in the browser: no
cookies, cache or history. User settings move to the server
(
~/.config/codeviewer/settings.json), and the bundled browser has no other job. - Network containment:
--host-resolver-rulesmaps every hostname except127.0.0.1to NOTFOUND, and--no-proxy-serverdisables proxies. The app window can only reach its own server. External links (⌘-click a URL in the terminal) are handed to the user's default browser by the server, which only acceptshttp(s)URLs. - Attack surface off: no extensions, sync, background networking, component updates, default apps, first-run, translate or media router. The sandbox is never disabled.
- Integrity: the build downloads a release and refuses it unless its SHA-256 matches the
digest published with the release. By default the build takes the latest stable release,
because a bundled browser that goes stale is a security liability. You can pin a version
with
--browser <tag>.
Desktop integration:
- macOS: a
Codeviewer.appbundle with the notarized Chromium inside. Launching it from Finder shows the native folder picker. - Linux: a
.tar.gzwith the binary, the browser and a.desktopentry. - Closing the window quits the app: the server stops and the PTYs are killed. Running from a
terminal works as before (
codeviewer [dir]). The slim browser-less binaries are still built, for headless/SSH use (--no-open) or for people who prefer their own browser.
Trade-off accepted: each app download gains about 150 MB for the browser. That's the price of being self-contained and pinned to a verified engine.
New requirement: on macOS, install from a .dmg, and skip bundling Chromium if possible.
Panel re-vote (browser-security engineer, desktop-integration specialist, release engineer):
WebKit is open source and ships inside macOS as WebKit.framework. Apple patches it through OS
security updates, so a bundled engine can't go stale. WebKit also runs page content in a
separate, sandboxed WebContent process. A ~250-line native AppKit shell (packaging/macos/main.swift,
using only system frameworks) gives a true Mac app: Dock icon, menus, native tabs,
drag-a-folder-onto-the-Dock, the folder picker and the window title proxy icon. The DMG drops from
~190 MB to ~40 MB. Chromium stays the bundled engine on Linux, which has no system web engine
we can rely on.
Hardening carried over from §8:
- A non-persistent
WKWebsiteDataStore: nothing survives the window. - A compiled
WKContentRuleListthat blocks every request excepthttp/ws://127.0.0.1. - A navigation delegate that only allows loopback. Any other URL, and every
window.open, goes to the default browser. - App Transport Security allows only local networking.
- The bundle is sealed with an ad-hoc code signature.
Lifecycle:
- Each window owns one server process (
--machineprints its URL as JSON), and closing the window stops it. --exit-with-parentmakes the server exit if the app dies.codeviewer <dir>in a terminal hands the folder toCodeviewer.app(open -n -a … --args --folder).- Codeviewer → Install 'codeviewer' Command… symlinks the bundled CLI into
/usr/local/bin.
The server reads git state through the system git CLI. A reimplementation would mean
supporting packfiles, the index format and worktrees, and every one of those is a place to
get it wrong. Every call is hardened:
GIT_OPTIONAL_LOCKS=0: never writes the index or takes locks.core.fsmonitor=false: never runs a hook the repository configures.diff.external=is empty,cat-file blobreads raw blobs, and nothing uses textconv or filters.- No prompts or pager. Arguments are passed as arrays.
Status is git status --porcelain=v2 -z --ignored scoped to the viewed folder (which can be a
subfolder of the repo). It refreshes 200 ms after worktree changes or after changes to .git
(index, HEAD, refs, merge/rebase files) and is pushed over the events socket only when it differs.
Gutter markers come from a client-side Myers line diff of the buffer against HEAD. Common
prefixes and suffixes are trimmed first, and a pathological middle section is capped at 2,000
edits, so a 100k-line file diffs in milliseconds. Blame runs git blame --porcelain -L n,n for the
active line only.
Three independent reviewers attacked the code and backed each finding with a repro or a measurement. Every confirmed finding was fixed and, where possible, locked in with a test:
| Area | Finding | Fix |
|---|---|---|
| Security (critical) | A malicious repo's .git/config + .gitattributes ran programs through filters, textconv and gpg |
GIT_ATTR_SOURCE = empty tree, every configured driver blanked, no signatures/textconv/submodules; regression test with a control run |
| Security (high) | Token visible to other users in the browser's argv | 0600 bootstrap page; osascript via stdin |
| Security (medium) | Cookies leak across 127.0.0.1 ports; text/plain CSRF; no Host check |
Header auth, no cookies; JSON-only writes; Host validation |
| Security (medium) | A FIFO in the tree hung file serving | O_NOFOLLOW and O_NONBLOCK opens, regular files only |
| Security (low) | Silent OSC 52 clipboard writes; macOS shell failed open; browser download not pinned | Toast on every write and none during replay; per-port rule lists that fail closed; browser-pins.json |
| Correctness | Bracketed-paste end marker could be rebuilt by nesting it | Strip every ESC/C1 from pasted text |
| Correctness | Replay after a reload lost alt screen, paste and mouse modes | Each replay chunk carries the mode prefix in effect before it; replay starts at a line boundary |
| Correctness | Emulator edge cases | Fixed: ZWJ emoji, BS with pending wrap, unterminated OSC/DCS, CAN/SUB, C1, CNL/CPL margins, EL wrap, wide-char erase, 1049 charsets, DECRC after resize, DECSTR, DECXCPR |
| Correctness | Regex literals broke JS highlighting; multi-dot file links; staged-new-file gutter; UTF-16; NFD names; Option key on non-QWERTY layouts; reload races | Fixed |
| Performance | One WebSocket frame per PTY read (298k frames for 52 MB) and no backpressure | Coalesced 4 ms/64 KB frames; high-water mark with a resync on drain |
| Performance | Line allocation per scroll; scrollback at 4.6 KB/line | Lines recycled, cleared only up to a write high-water mark; history compacted. yes 9.3 → 13 MB/s, memory −62% |
| Performance | Per-keystroke and per-frame linear work | Quick-open narrowing + top-k; O(depth) git lookups; ASCII natural sort; incremental file index; adaptive git debounce; render windows in the tree and code view; glyph-width cache |
Users update from inside the app ("Check for Updates…", or the status-bar pill). A self-updater is a remote-code-execution channel by design, so it gets the same treatment as the terminal:
-
Authenticity, not just integrity. The release workflow signs
SHA256SUMSwith an Ed25519 key that exists only as an Actions secret (CODEVIEWER_UPDATE_KEY). Every build embeds the public key (packaging/update-key.pub). Before anything is installed, the updater checks four things:- The signature is valid, so a compromised download host or a swapped release asset is rejected.
- The downloaded file's SHA-256 matches its line in the signed list.
- The signed file name carries the new version.
- The version is strictly newer, so no rollbacks.
On macOS the staged app must also pass
codesign --verify --deep --strict. Verification uses WebCrypto, so it adds no dependencies. -
Atomic, with rollback. Updates are staged next to the target, on the same file system, and swapped in with
rename: the.appbundle, the Linux package directory, or the CLI binary. If the swap fails, the old copy is put back. Nothing new runs until verification passes. -
Restart. The Mac shell reopens every window's folder in the new version. The Linux app window starts the new binary and closes itself. CLI users are told to restart. Running from source is never auto-updated (
git pull). -
Privacy. One request to
api.github.comper launch, which can be turned off (setting, orCODEVIEWER_NO_UPDATE_CHECK=1). -
Tests run the real updater against a fake GitHub:
- Successful installs of the CLI, the Linux package and a real code-signed DMG.
- Rejection of a forged signature, a checksum mismatch, a rollback, an unsigned release, a missing key, and a tampered app bundle. In each case the installed copy must stay untouched.
Brief. Add project-wide content search. Four panelists reviewed it: the interaction designer, the systems/performance engineer, the application security engineer, and the frontend architect & test lead. Before this, the app had only ⌘P (fuzzy file names) and ⌘F (the open file).
Where it lives (interaction designer). Search is a second sidebar view next to the Explorer. It is not a palette mode, because the palette closes when you act and search results need to stay visible while the agent works. It is not a panel either, because the terminal owns that slot.
- ⌘⇧F (Ctrl+Shift+F) shows the sidebar, switches to Search, and focuses and selects the query. It works from the terminal too.
- The existing
mod && k === "f"branch inapp.tsignores Shift, so the new branch must come first and the old one gets!e.shiftKey. - The current sidebar view is saved in layout state (
L.sideView). - Decision: adopted.
Query options. Search uses the same three toggles as the find bar: Aa (match case),
ab (whole word) and .* (regex), with the same labels and aria-pressed. Matching uses the same
rules as codeview.ts, so a hit in the project is a hit in the file.
- There are optional include and exclude globs, comma-separated, matched with
Bun.Glob. - Deferred: replace (never; the app is read-only), multiline regex, search history.
- Search in Folder… (folder context menu in the Explorer) switches to Search and sets the
include field to
<folder>/**, re-running any current query. Glob metacharacters, commas and whitespace in the folder name are backslash-escaped, so the scope stays literal:parseGlobstreats\,as a literal comma (not a separator) and the client's trim cannot eat edge whitespace. The scope is set before the view is revealed, so exactly one request is made. A git-ignored folder turns "Include ignored files" on. An existing exclude is kept (user-authored filter), so an exclude that covers the folder yields no results. A scope longer than the server's 1,024-character cap shows a message instead of an invalid-query error.
Engine (performance engineer). Matching runs in the server, in a Bun Worker, using sync
I/O into a buffer that is reused.
- Measured on a corpus of 68k files and 360 MB:
- One worker took 1.5 s and two took 0.89 s. The first hit arrived in 2 ms.
rg -uuutook 2.3–3.1 s.- Literal and regex searches cost the same, because the time goes to I/O.
rgwas rejected: it isn't installed everywhere, and the app has a zero-dependency rule.git grepwas rejected for matching:- It only works inside a git repo.
- It uses POSIX/PCRE regex instead of JS regex, so its results would disagree with the highlights in the client.
- It brings in more git attack surface.
- Decision: v1 uses one worker. A pool of two is a planned improvement if a 200k-file scan misses its budget.
- The worker also serves as the kill switch for runaway regexes.
Candidate files.
- In a git repo:
git ls-files -z --cached --others --exclude-standard --deduplicate -- ., run through the hardened git runner, so.gitignoreis respected without writing a parser. The list is cached and invalidated on watcher batches. - Outside a repo, or with "Include ignored files" on: the
FileIndexlist. That list still skipsHEAVY_DIRS.
Matching rules. The query compiles to one JS RegExp (g, u, plus i when case-insensitive).
- Literal queries are escaped.
- Whole word uses the find bar's rule.
- A case-sensitive literal search first checks the raw bytes with
Buffer.indexOf, so most files are never decoded. - Decoding follows
readForView: strip the UTF-8 BOM, transcode UTF-16 with a BOM, and use U+FFFD for invalid bytes. - Lines split on
\r\n|\r|\n, the same as the client, so line numbers agree. - Columns are UTF-16 offsets.
- Zero-length matches advance and are dropped.
- A match that spans lines is clipped to its first line.
Security (application security engineer). Non-negotiable:
- The user's pattern never runs on the main thread, which carries the PTYs.
- The main thread enforces a 10 s wall-clock deadline and also cancels when
req.signalaborts. - It cancels by setting a cancel flag, then calling
worker.terminate()if the worker doesn't stop. - A test must prove that
terminate()stops/(a+)+$/running on"a".repeat(40)+"b".
- The main thread enforces a 10 s wall-clock deadline and also cancels when
- Only one search runs per server: the newest wins and cancels the old one.
- Limits:
- The query is at most 256 UTF-16 units, with no control characters.
- The response stops at 2,000 matches in total, then reports
truncated. - At most 100 matches per file, followed by a
morecount. - Files over
MAX_TEXT_BYTESand binary files (looksBinary) are skipped and counted. - Previews show about 200 characters around the match.
- Every candidate file is re-opened with
O_NOFOLLOW|O_NONBLOCK, and the code checks that it is a regular file fromfstat.FileIndexlists symlinked files, so a link to~/.ssh/id_rsamust not be read. Files are de-duplicated bydev:ino. - Per-file errors are swallowed and counted in
skipped. Top-level errors return fixed strings, nevere.message, because that can contain absolute paths. Queries are never logged. - Client: paths and previews come from the repo, so they are untrusted.
- Every slice is escaped with
esc(), and only the match is wrapped in<mark>. - Rows are referenced by
data-i, and paths never go into attributes. - C0/C1 controls and bidi overrides are shown as visible escapes, against Trojan Source.
- Every slice is escaped with
git grep, if it is ever added, must use-e,-F/-E,--no-textconv,GIT_LITERAL_PATHSPECS=1, a kill timer and--.
API (frontend architect). GET /api/search?q=®ex=1&case=1&word=1&include=&exclude=&ignored=1
returns 400 {error} for an empty query, a query that is too long, or an invalid regex.
Otherwise it returns 200 application/x-ndjson:
interface SearchMatch { line: number; col: number; len: number; preview: string; pcol: number }
type SearchEvent =
| { t: "file"; path: string; matches: SearchMatch[]; more?: number }
| { t: "done"; files: number; matches: number; skipped: number; truncated: false | "matches" | "time"; ms: number; error?: true };
If the scanner fails after output has started, the stream still ends with a done line carrying
error: true (no message).
The search does not use /ws/events: that socket broadcasts to everyone and can't cancel a
single request.
Results UI.
- Results are grouped by file, and each group can be collapsed. A file header shows its icon, name, dimmed folder and match count.
- Each line row shows the line number in tabular figures and a preview that starts about 30
characters before the match with "…". Matches are wrapped in
<mark>, and the selected one inmark.cur. - A summary line reads "312 results in 41 files", and the honest-cap banner reads "Showing first 2,000 results".
- No virtualization in v1: the 2,000-match cap bounds the DOM.
- Keyboard:
- ↑/↓ move the selection.
- Enter opens the result in the preview tab and keeps focus in the list.
- Esc in the list returns to the query; Esc in the query clears it.
- F4 / ⇧F4 move to the next or previous result from anywhere except the terminal.
- Live search runs as you type: after a 250 ms debounce, at 2 or more characters (1 for regex).
Enter runs it immediately. Each new search aborts the previous fetch through
AbortControllerplus a generation counter.
Opening a result.
openFile(path, {line, col, len})goes througheditor.openand thenCodeView.showRange.showRangemarks the match as the current mark and scrolls it into view horizontally, sharingrevealColwith the find bar.- The marking logic is factored into a pure
markRangesso it can be unit-tested.
Live changes.
- When a
changedevent touches a path that is in the results (or reportsmany), a "Results may be out of date · Refresh" bar appears. Refresh re-runs the query. If the Search view is hidden, the results are marked stale and refreshed when it is shown again. - Deferred: re-searching only the changed paths in place.
States.
- Empty query: a hint with the shortcuts.
- Searching: a thin progress bar after 150 ms.
- No results: "No results for …".
- Invalid regex: the input turns red and shows the message, as in the find bar.
- Skipped files: "N binary/large files skipped".
- Pure matcher
src/server/search.ts:compileQuery,matchText(lines, cols, previews, caps). - Scanner + Worker +
GET /api/searchNDJSON endpoint, with containment, binary/large skips, caps, cancel and the regex-DoS kill. - Candidate list:
git ls-filesin repos,FileIndexotherwise, include/exclude globs, ignored toggle. - Client
api.search+ NDJSON splitter +SearchView: grouped results, summary, truncation, errors, abort. - App wiring: sidebar Explorer/Search switch, ⌘⇧F, commands, open result at line/col with
showRangehighlight, F4/⇧F4, keyboard nav. - Staleness bar on
changedevents; docs (README shortcut table, DESIGN.md architecture/REST list).
- Anchors. The worker adds the
mflag, so^and$match at every line, the same as the client find bar, which tests one line at a time. - Containment. Each candidate's
realpathis checked inside the worker; a path that resolves outside the root counts as skipped, as do binary, oversized and non-regular files. - Caps.
includeandexcludeare limited to 1,024 characters and 64 patterns each (400 otherwise). - Collapsed groups. Keyboard navigation follows the VS Code convention: ↑/↓ skip the rows of a collapsed group, and F4 / ⇧F4 expand the group they land in.
- Coverage. The worker (
search.worker.ts), the build script,app.tsandeditor.tsare excluded from line coverage: they run in a separate thread, the bundler or the real DOM, and are exercised through the endpoint, build and manual runs instead.
Brief. Render .md and .markdown files as formatted documents, with the source one click away, no external
dependencies of any kind (the parser and renderer are written in this repo), and repo content treated as untrusted.
Four panelists reviewed it: the interaction designer, the application security engineer, the systems/performance
engineer, and the frontend architect & test lead. Before this, a Markdown file opened as highlighted source.
Preview and source (interaction designer). A text tab for a Markdown file opens as a rendered preview. The
existing tab.asSource flag and viewToggle button, which already switch an SVG between image and source, are reused:
"View source" in preview, "Preview" in source, same button and position. There is no second tab for the same path.
- Opening with a line (search hit,
file:line,:in the palette, Go to line) forces source view, because a line has no meaning in the preview. Find (⌘F) in the preview switches that tab to source and opens the find bar there, so it never falls through to the browser's find silently. - The mode is per tab and survives live reloads: a file edited on disk while in source stays in source.
- A palette command "Toggle Markdown Preview" is added.
- Prose uses the system UI font with a centred column of about 78ch; code uses the existing mono font. All colours come from the existing theme tokens. Headings h1/h2 have a hairline underline; tables have hairline borders; task-list checkboxes are disabled, because the product is read-only. Text size follows the zoom.
- Deferred: an "On this page" outline, a Cmd/Ctrl+Shift+V shortcut, find inside the preview, link hover in the status bar, copy button on fences, flashing changed blocks, persisting the mode across restarts.
- Decision: adopted (except the deferred items above).
Escaping model (application security engineer). Script execution in the page equals shell access, so the escaping model is the primary defence and the CSP is the backstop. The CSP is not loosened.
- The renderer builds DOM with
createElementandtextContentonly. It never usesinnerHTMLand never thehtml:prop ofh. The security engineer first proposed a string renderer with oneinnerHTML; the performance engineer and the architect argued for DOM building (no escaping-bug class, nodes can be replaced individually, and the fake DOM in tests cannot inspectinnerHTML). The security engineer accepted, on the condition that a test walks the output tree and fails on any tag outside a closed set (p, h1-6, ul/ol/li, blockquote, pre/code, table family, a, img, em, strong, del, hr, br, span with atok-*class, input checkbox disabled), anyon*,style,srcdocornameattribute, and anyidthat is notmd-prefixed. - Raw HTML (inline and blocks, comments included) is shown as literal text, never parsed. No allowlist in v1.
- Link URLs go through one pure function,
safeHref. Allowed:http:,https:,mailto:,#fragmentand relative paths. Everything else (javascript:,data:,vbscript:,file:,blob:, unknown schemes, protocol-relative//host, backslashes) is rendered as its label text with no<a>. The URL is probed after stripping control, space and zero-width characters and decoding entities, sojava\tscript:andjavascript:are caught. - External links get
rel="noopener noreferrer nofollow"andtarget="_blank". Relative links never navigate: a delegated click handler resolves the path against the document's folder (collapsing.and.., percent-decoding once, rejecting anything that escapes the root) and opens it through the editor. The server'sresolveSafestays the real boundary.#fragmentclicks scroll inside the preview and never touchlocation.hash; heading ids use anmd-prefix, to avoid DOM clobbering. - Relative images load only through
api.rawBlobUrl, into an<img>with a blob URL (SVG is script-inert there and is never inlined). The extension must be an image type the server serves, sois never fetched. Blob URLs are revoked on re-render and on close. At most 100 images per render. - Remote images (
http(s),//host,data:) never load. The CSP already blocks them andimg-srcis not widened, so consent loading is impossible without relaxing it. The renderer shows a placeholder with the alt text and the host, and a link to open the URL in a new tab on an explicit click. A per-image "load" action is deferred until a CSP decision is made. The interaction designer wanted consent loading; the security and performance engineers wanted none in v1; the architect noted the CSP forces "none". - Trojan Source: code spans, code blocks and link titles show C0/C1 controls and bidi overrides as visible escapes,
reusing the
HIDDENrule that now lives inutil.ts(moved fromsearch.ts). Blocks getdir="auto". The fence info string is only a lookup key ([a-z0-9+#-]{1,20}, mapped to a known language), never emitted into an attribute. - Decision: adopted.
Parser and limits (performance engineer). Rendering is client-side from the text already fetched. No server change and no new endpoint.
- The parse is linear: a line-oriented block pass with an explicit container stack (no recursion), and a CommonMark-style delimiter stack for emphasis. No regex with nested quantifiers touches untrusted text.
- Hard limits degrade to literal text and never throw: container nesting 32, inline nesting 32, 10,000 link
definitions, 200 table columns, 10,000 characters per inline line, about 200,000 rendered nodes. Tests run the
adversarial corpus (200k
*, 100k[, 100k>, 100k-, 1e5 backticks, with generous time bounds, and check that doubling the input does not more than triple the time. - Files over 1 MiB open in source with a "Too large to preview · Render anyway" note. The panel proposed 2 MiB
(performance) and 1 MiB (designer, architect, security); the lower cap won, because there is no chunked rendering.
A truncated file (
x-cv-truncated) shows the existing truncation pill above the preview. - Fenced code highlights with
lexLinefromhighlight.ts, threading its state line by line. The stateful per-fileHighlighteris not used. Fences over 2,000 lines or 200 KB render as plain text. The architect and the performance engineer differed onHighlighterversuslexLine;lexLinewon because it needs no file-wide state. - A live update re-parses (cheap and linear), builds the new tree off-screen, swaps it with
replaceChildrenin one frame, and restoresscrollTop. Identical text skips the render. A reload while in source mode does not render the preview; it is rendered when shown. Closing a tab drops the tree and the parsed blocks. - Deferred: chunked/idle rendering, incremental node reuse, a parsing worker, lazy highlighting by visibility,
content-visibility. The 1 MiB cap and the node cap bound the cost until measurements say otherwise. - Decision: adopted (chunked rendering and node reuse deferred).
Structure and tests (frontend architect & test lead). Three modules with hard boundaries:
src/client/markdown.tsis pure:parseMarkdown(src)returns a typed AST; it also exportsisMarkdown,safeHref,resolveRepoPathandslugify(GitHub-style, de-duplicated with-1,-2). It has no DOM and no imports besides types, so its tests run without the fake DOM.src/client/mdrender.tsturns the AST into DOM andsrc/client/mdview.tsisMarkdownView { el, setText, dispose }, mirroringCodeView.setText.editor.tsgetsmd?: MarkdownViewonTaband a guard on everytab.codeconsumer (status, go to line, find, git refresh, font size).- Syntax: CommonMark core (ATX/setext headings, paragraphs, fenced and indented code, nested block quotes, nested and
loose/tight lists, thematic break, link reference definitions, emphasis, code spans, links, images, autolinks,
escapes, hard breaks, a small entity table decoded to text) plus GFM (tables with alignment, task lists,
strikethrough, bare URL autolinks). Not supported in v1: raw HTML, footnotes, math, diagrams, front matter. Full
CommonMark conformance is not a goal; correctness is pinned by hand-written cases in
test/fixtures/(authored in this repo, not copied from the spec) plus a seeded fuzz test that checks the safety invariants on 2,000 random inputs. editor.tsandapp.tscannot be loaded under the fake DOM, so they are excluded from line coverage, as in Round 4. Everything with logic lives in the three modules above, which are covered.- Decision: adopted.
markdown.tscore +MarkdownView+ editor wiring: headings, paragraphs, source/preview toggle, live update with scroll kept,tab.codeguards,file:lineforces source, find and Go to line switch to source. DESIGN.md section.- Block and inline core: lists, quotes, code blocks, thematic break, emphasis, code spans, escapes, entities, hard breaks, caps and the adversarial perf corpus.
- Links:
safeHref,resolveRepoPath, external and repo-relative links,#fragmentscrolling, heading slugs, autolinks. - GFM: tables, task lists, strikethrough; theme styles in
styles.css. - Fenced code with
lexLinehighlighting, size caps, info-string allowlist. - Images (relative via blob, remote placeholder), size gate with "Render anyway", palette command, README and SECURITY notes, fuzz test.
Brief. Let the user review what an agent changed: both the uncommitted work and everything the
branch has accumulated since it left its base branch, since agents commit every few minutes. Three
panelists reviewed a draft: the interaction designer, the visual/information designer, and a
developer-experience researcher who argued for the smallest product that does the job. Before this,
the app had gutter markers vs HEAD with click-to-peek, and ⌘⇧G opened a palette list of changed
files (a binding that, it turned out, was advertised but never wired).
Where it lives (all three). A third sidebar view, "Changes", next to Explorer and Search, for the reason Search is one (§13): the list must stay visible and keep updating while the agent works, and a palette closes when you act. Not a panel (the terminal owns it), not a tab (it would fight the file being read).
- ⌘⇧G shows the sidebar, switches to Changes and focuses the list. The status bar's branch and change-count buttons do the same. The palette list is gone: two surfaces for one list is a discoverability and maintenance tax.
- Decision: adopted.
Scope (disagreement, resolved). The interaction designer wanted a single list, branch vs base,
falling back to HEAD, because a switch makes the user choose before seeing anything and
"uncommitted" is a shrinking subset of "branch". The other two wanted both scopes: "what did the
agent do since the last commit" is a real question mid-task. The DX researcher settled the semantics:
the branch scope compares the working tree with the merge-base (not HEAD with the merge-base),
because the file the agent is editing this second is the one the user most wants to see; the two
scopes are therefore nested.
- A segmented control (Uncommitted · vs main) appears only when a base branch exists and HEAD has commits on top of it; otherwise there is one list and the summary reads "vs HEAD". The default is the branch scope.
- Base detection (server): the remote's default branch (
refs/remotes/origin/HEAD), else the first ofmain/master/develop/trunkthat exists and is not the current branch, else the upstream. Onmainitself the base isorigin/main, so unpushed commits are what the branch scope shows. A detached HEAD still compares with the base. No user-configurable base in v1. - Decision: two nested scopes, the control only when they differ.
Summary and list (visual designer). One 28px summary line, mono tabular counts: 7 files · +120 −38 · 3 commits, the base right-aligned and faint, a 2px two-colour bar under it. The commit
list was cut (the DX researcher: nobody reviews agent output commit by commit; the count answers "did
it squash?") and lives in the tooltip of the count. Rows are 24px like the tree: badge, basename,
faint directory, fixed-width +N −N columns so counts align without a table. Names are not tinted
(the counts already carry colour). Renames show old → new, deletions strike the name through,
binaries say bin, conflicts get a left rule. Surprises sort first: conflicts, deletions, new files,
renames, then edits. A file that was opened dims ("reviewed") until it changes again, so a finished
review is a list of dim rows (the interaction designer's "know when you're done").
- Decision: adopted.
Diff tab vs re-based gutter (disagreement, resolved). The interaction designer proposed no new
view: re-base the existing gutter markers on the merge-base and keep deletions behind click-to-peek,
to avoid a second renderer. The other two held that reading deletions is the core of reviewing agent
output ("did the tests shrink?") and that a peek-per-hunk does not scale to a 40-file review. The
diff tab won, kept lean: unified only (side-by-side needs ~160 columns; sidebar + terminal on a
laptop leaves ~70), two line-number columns and a +/− marker, row backgrounds from the existing
git tokens at 12% (20% in the number cells), syntax highlighting untouched, three lines of context
with a clickable "N unchanged lines" row, virtualized like the code view. No word-level diff in v1.
The interaction designer's two fixes were kept: deleted files open (their old side comes from the
merge-base blob), and ⌥F5/⌥⇧F5 walk the hunks of a diff and, at the ends, move on to the
next/previous file, so a review is one key held down. The header shows the file, +/−, the base,
n/N with ‹ › buttons, and "Open file" (the live file at its first change).
- Decision: unified diff tab; no side-by-side, ever.
Live behaviour (DX researcher). A stale review list in an agent-watching tool is a bug. The list refreshes on every git status change while shown (or while diff tabs are open); diff tabs reload when their file changes on disk and re-diff when the base moves (a commit in the uncommitted scope), keeping their scroll position. A file the agent reverts keeps its tab and shows "No differences". Diff tabs are not restored across sessions.
- Decision: adopted.
Engine and security. One new endpoint, /api/git/changes?scope=, built from git diff --raw --numstat -z -M <rev> -- . (raw records give status and rename pairs, numstat the counts; both are
parsed as NUL-separated records, never lines) plus git ls-files --others --exclude-standard for
untracked files, whose lines are counted by the server for the first 200 files under 1 MiB, opened
with O_NOFOLLOW so an untracked symlink to ~/.ssh is never read. The list is capped at 1,000
entries. The old side of a diff reuses /api/git/base with a rev parameter that must be a full
hex object id (any object reachable in the repository is accepted, since a token holder has a shell
anyway; never a ref name, never --prefixed); with a rev the path
need not exist on disk (deleted and renamed files) but is checked syntactically. Everything runs
through the hardened runner of §10. Client: paths and subjects are escaped and control characters
made visible (Trojan Source), rows are addressed by index, never by path in an attribute.
Cut from v1, by the panel: commits list (count only), user-chosen base, whitespace toggle, word-level diff, side-by-side, anything that writes (staging, commenting, stash). Those are an SCM client; this is a viewer.
src/server/git.ts:changes(),detectBase(),base(rel, max, rev),isOid,MAX_CHANGES.src/client/changes.ts:ChangesView(scope control, summary, sorted rows, keyboard, reviewed dimming, live refresh) and the pureletterOf,labelOf,sortChanges,summarize.src/client/diffview.ts:DiffView(header, virtualized unified rows, gaps, hunk stepping) and the purebuildRows,spansHtml.src/client/editor.ts: diff tabs (openDiff,refreshDiffs,setDiffPositions); ⌥F5 falls through to the next file.src/client/app.ts: the Changes side view, ⌘⇧G and ⌥F5 bindings, the "Git: Review Changes" and "Git: Compare With Base Branch / HEAD" commands.- Tests:
servergit.changes,http.changes,client.changes,client.diffview.
Three independent reviewers attacked the feature with repros and measurements. Every confirmed finding was fixed and locked in with a test:
| Area | Finding | Fix |
|---|---|---|
| Security (high) | diff.orderFile=/dev/zero in a hostile .git/config made the new git diff grow past 4 GB and never return; mailmap.file=/dev/zero did the same to git log (and the pre-existing log -1/blame) |
-O/dev/null on the diff, --no-mailmap on every log, mailmap.file=/dev/null, mailmap.blob= and log.mailmap=false in HARDEN; a 30 s kill timer on every git run |
| Security (medium) | diff.relative=true made paths cwd-relative, so a subfolder view silently listed nothing; core.bigFileThreshold=1 turned every count into "bin" |
--no-relative; core.bigFileThreshold=512m in HARDEN |
| Security (low) | 200 untracked files × 1 MiB read concurrently into full-size buffers; a rename from outside the viewed folder showed a path above the root | Streamed counting through one 64 KiB buffer, 8 at a time; such renames are shown as added, without the old path |
| Correctness (high) | Merge conflicts never appeared: git diff <commit> compares a tree with the working tree and never emits unmerged entries, so the whole conflict path was dead code |
git ls-files --unmerged overlays U entries (test: UU and modify/delete) |
| Correctness (high) | Past the 1,000-file cap the parser stopped before the numstat section, so every row lost its counts and never un-dimmed | Skip entries over the cap but keep parsing; truncated is set only when something was cut |
| Correctness (medium) | A diff tab kept a stale status when its file left the list: an added file that got committed showed entirely green instead of "No differences"; a restored deleted file stayed "deleted" | A file missing from the list is re-diffed as a plain modification |
| Correctness (low) | git rm --cached listed the same path twice (D and ?); submodules were listed as M +1 −1; an orphan branch with a staged index showed nothing; EOL-only changes read "No differences" while the list said +1 −1 |
Merged into one M entry; mode 160000 skipped; the empty tree is the diff base of an unborn branch; "Only line endings or the final newline changed" |
| Performance (medium) | /api/git/changes ran ~10 git processes per call with no coalescing (4 concurrent callers: 650 ms on 100k files) and the client fired a request on every status push |
One in-flight run per scope on the server; the client keeps one request in flight and re-runs once afterwards |
| Performance (low) | Every reload of a diff tab refetched the unchanged old blob; ArrowDown rebuilt 1,000 rows | The old side is cached per tab by rev and path; selection moves in place |
Measured and left alone: a 100k-line file with every other line changed builds 200k rows in 2.3 ms and renders a window in 0.6 ms; one change at line 99,990 costs 9.5 ms including lexing both sides; a diff tab of an 8 MB file holds 11.6 MB.
Four reviewers worked independently from the code and backed each finding with a probe or a test; the two highest UX findings were raised by two reviewers each. Every finding below was fixed and, where a harness exists, locked in with a test.
| Area | Finding | Fix |
|---|---|---|
| Security (critical) | git -c key=value splits at the first =, so a hostile repo naming a driver a=b kept its filter/textconv armed despite the round-3 blanking |
Overrides travel as GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n (key and value separate); regression test with an a=b driver bound from .git/info/attributes |
| Security (low) | Updater fetched any asset URL the API named, with no size limits; a multibyte token header crashed the comparison; PTY upgrade sizes were unbounded | Assets only over https from GitHub's hosts, 1 MB cap on sums/signature and 1 GB on the download; byte-length token compare; upgrade sizes clamped like resizes; CSP from the request origin |
| Security (low) | Markdown external links navigated the locked-down app window itself | Handed to the default browser through /api/external, like terminal links |
| Server | Linked worktrees showed the main checkout's merge/rebase; nested repos listed as untracked files; deleted/submodule candidates counted as "binary/large files skipped"; *.ts matched only the root; codeviewer open failed from a symlinked cwd; a lagging client lost the final output before exit |
Per-worktree op markers; editor-style globs; honest skip counts; realpath of the parent; replay before the exit notice |
| Client | DCH/ICH on the second half of a wide character orphaned the WIDE flag (a column vanished); a ZWJ joined the glyph printed after a cursor move; DECSTR kept DEC graphics; C0 bytes landed in OSC titles; closed tabs still built views for loads in flight; terminal views leaked window listeners; a change during an in-flight listing was dropped; tab-indented widths; an empty file at an empty HEAD showed "+1"; an empty list item ended a Markdown list | Fixed, with emulator, tree, code-view and parser tests |
| UX (high) | Linux: Ctrl+Shift+C with no selection sent ^C to the agent; Ctrl+P, Ctrl+=/−/0 never reached the terminal; ⌘W killed a live terminal with no guard; with "Reduce motion" every toast (incl. the OSC 52 notice) and the changed-lines flash were invisible; a lost connection was a 7 px dot | Copy chord is a no-op without a selection; Ctrl shortcuts stay out of a focused terminal on Linux; ⌘W closes exited terminals and asks for a second press otherwise; reduced-motion styles show instead of fade; "disconnected, reconnecting…" in the status bar plus a toast |
| Accessibility | No live region, invisible focus stops (tab close buttons, search results), dialog did not take focus, focus lost after closing a tab, faint text at 2.1–2.9:1 | aria-live region fed by every toast; focus-visible styles; dialog focus in/out; next tab focused; secondary text ≥ 3.4:1 and comments ≥ 4.2:1 in both themes; light "bright white" darker than normal text |
| Keyboard | Terminal tabs, the explorer context menu and reattaching a session taken by another window were mouse-only | ⌘⇧[ / ⌘⇧] switch terminals when the terminal is focused; Shift+F10 / Menu key in the explorer; a keystroke reattaches |
A second pass took every remaining item, including the ones first judged not worth the change:
| Area | Finding | Fix |
|---|---|---|
| Security (info) | Every terminal process held the full session token; updater prerelease ordering compared as strings | Terminals get a second token that only /api/open honours; semver prerelease identifiers compared per the spec |
| Server | A search superseded by a newer one ended its stream with no summary; /api/raw could send a short body after a shrink and could not seek; detectBase picked a stale local master over origin/main; an ignored subfolder viewed as root looked clean; symlinked folders were indexed as files and forced full rebuilds; a shell that exited while no page was attached lost its exit notice |
Flagged final done line; Range support with 206/416 and a failed body on a short read; upstream before local fallbacks on a base branch; rootIgnored in the status (the client dims everything); symlinked folders inside the root walked once, outside ones dropped; exited sessions kept for a reattach or 60 s |
| Markdown | Lazy continuation accepted ordered markers and setext underlines inside containers; link reference definitions were single-line only; any open [ disabled every later autolink |
CommonMark rules for all three, with tests |
| Terminal | No screen-reader output; OSC 8 links hid their target | A hidden role="log" live region receives each completed line (not in the alternate screen, never during replay); hovering a link shows its target, prefixed with "Link target:" when the label looks like a different host |
| Client | Edits inside a hunk reset opened gaps and the current hunk on live reload; a line requested for a tab still loading was dropped; YAML values such as url: http://… were coloured as keys; DCH/ICH probes |
Same-shape reloads keep the reader's place; the line is applied when the load lands; a YAML key's colon must be followed by space or end of line |
| UX | Chrome could not be scaled; splitters, terminal tabs, diff gaps and the HEAD peek were mouse-only; same-named tabs were indistinguishable; unreadable folders looked empty; long paths clipped without an ellipsis; shortcut labels mixed macOS glyphs into Linux; ⌘-click and codeviewer open were taught once; closing the window could kill a running agent silently |
⌘= / ⌘- also scale the chrome (zoom on the chrome containers); focusable splitters with arrow keys and aria-valuenow; ⌘⇧[ ] switch terminals from the terminal; gaps open with Enter; "Git: Peek HEAD on Current Line"; a muted folder hint on duplicate names; "Can't read folder: …" and "Loading…" rows; ellipsized status items; platform key labels everywhere; a ⌘/ help sheet with every shortcut and both tips; beforeunload asks while a program (title changed) runs in a terminal |
| Accessibility | Lists lacked aria-activedescendant; the palette was not a combobox; the find count was silent |
Stable row ids and active-descendant wiring on the explorer, Changes list, search results and palette; combobox semantics; the find count is a live region |
| Finding | Fix |
|---|---|
| The welcome screen's shortcut list and tip painted over the terminal panel head when the panel was tall, and a tab bar did the same when the editor was squeezed to a few pixels. In-flow text of an earlier sibling paints after a later sibling's background (CSS paint order), so an unclipped editor "shows through" an opaque panel. | .editor { overflow: hidden }, the welcome view scrolls inside the space it has, and the panel is capped at the main area minus 120 px (160 px when docked right) so a size saved on a taller window never hides the editor. |
At a UI scale other than 1 the help sheet overflowed the window: WebKit scales vh/vw inside a zoomed element, so max-height: calc(100vh - 32px) grew with the zoom. |
Dialog sizes are percentages of the (unzoomed) overlay, which carries the 16 px margin as padding. |
| ⌘A on a rendered Markdown file selected the whole page (sidebar, tabs, status bar). | MarkdownView claims select-all for the visible preview tab, exactly like CodeView does for code, and selects the rendered document's contents; inputs and the terminal keep the key. |
The PTY tests sent their first command before the shell printed a prompt. On Linux the tty echoes the input first, so the prompt lands in front of the output (# READY) and exact-line waits timed out: CI's Ubuntu job failed while macOS passed. |
Waits accept a prompt prefix; the suite was reproduced and fixed in a Bun 1.4.4 container. |
| Two identical yellow dots on a folder: git's "contains changes" and the "changed on disk" marker. | A folder git already flags gets one marker; the git dot has a tooltip. |
| The macOS About panel had no owner or links. | Credits name 21no.de, the site and the repository as links; the copyright line in Info.plist names the owner and license. |
| A modified badge in the Explorer led nowhere: seeing a file's diff meant switching to Changes and finding it in the list. | "View Changes" in the file context menu (every file inside a repository: the badge is the working tree vs HEAD while Changes may compare with the base branch) opens the Changes view with the file's diff focused as a preview tab. The list is brought current first through ChangesView.ready(): the view's own refresh on being shown would make a plain await refresh() return early (again flag) with the old list and a false "No changes". A file not in the comparison gets a notice, not an empty diff. |
⌘/ did nothing on a German Mac. There "/" is Shift+7, and WKWebView reports ⌘⇧7 as key: "7", code: "Digit7" with Shift held (recorded with a CGEvent probe on the German layout), so the handler's key === "/" never matched. Linux reports Ctrl+Shift+7 as "/" and was fine. |
helpKey() in keys.ts also accepts ⌘⇧ plus Digit7 reporting "7". The Slash code is not matched: on those layouts that key is "-" and ⌘- zooms out. The help sheet and README mention ⌘⇧7. |
Four experts proposed five read-only features each, independently and without seeing each other's lists: a developer who watches coding agents all day, a UX/accessibility designer, a code-navigation engineer and a git/security engineer. Constraints: nothing writes to the tree or the repository, no dependencies, no new network calls. Three ideas were picked by more than one expert; they were built. The rest (soft wrap, a high-contrast theme, a turn checkpoint diff, follow-agent mode, a repository trust report, a hidden-character scanner, back/forward history, and others) each had one vote.
| Feature | Votes | As built |
|---|---|---|
| Go to Symbol / outline | Navigation #1, UX #3 | symbols.ts: one start-of-line pattern per declaration form for 17 language families, comment and docstring lines skipped, depth from indentation; Markdown ATX/setext headings outside fences. ⌘⇧O or @ in ⌘P shows an indented outline that typing turns into a ranked list. In a rendered preview a heading is revealed by its slug (de-duplicated like the renderer), so the preview stays a preview. |
| File History | Navigation #5, Security #4 | Git.history() runs --literal-pathspecs log --follow -z --name-status (first-parent diffs for merges, 500 commits, 4 MB). The output is read as NUL-separated tokens by position, so a subject or author can never act as a separator. Paths are made root-relative (a rename from outside the root ends there as "added"). The palette's new pick() lists the commits; one opens as a diff tab whose two sides are blobs by object id. |
| Copy Reference | UX #4, Agent-dev #5 ("review notes to terminal") | ⌥⌘C copies path:line or path:12-40; a selection ending at column 0 of a line leaves that line out. "Copy Reference with Code" adds the whole lines in a fence longer than any backtick run inside. "Insert Reference in Terminal" pastes the one-line reference at the prompt and never presses Enter. Terminal ⌘-click and codeviewer open now accept path:12-40 (first line). |
Findings while building:
| Finding | Fix |
|---|---|
Diff tabs were found by path and every isDiff tab followed the Changes view: a history tab would have been re-pointed at the Changes base on every git event, merged with the Changes diff of the same file, given "3/7" positions, kept the Changes view polling, and ⌥F5 past its last hunk would have jumped into the Changes list. |
History tabs carry their commit and are keyed by path + object id. openDiff, refreshDiffs, setDiffPositions, changes.watching and the step past the last hunk skip them, and file-change reloads never touch them (a commit does not change). Tested in client.history.test.ts. |
The palette's fuzzy() jumped to a later word-boundary or camel-case hit even when the rest of the query could not match after it: sto did not match stop<T> (the T looked like a hump). Symbol names hit this often; paths rarely. |
A later boundary hit is taken only when the rest of the query is still a subsequence after it. |
Option changes the key on a Mac (⌥C is "ç"), so key === "c" never matches ⌥⌘C there. |
referenceKey() also accepts the physical C key when the key is not a letter; a letter there means another layout (Dvorak), where only "c" itself counts. |
A path from the client goes into a git pathspec; a file named :(top)x would be read as pathspec magic. |
--literal-pathspecs on the history call, with a test using such a file name. |
diff.orderFile is read whole by diffcore; diffFiles passed -O/dev/null, but log --follow sets up its own diff and ignores -O, so a repository with diff.orderFile=/dev/zero held File History for the 30 s timeout. |
diff.orderFile=/dev/null joins the fixed hardening config every git call gets; the malicious-repository test now also sets diff.orderFile and diff.external and runs history(). |
File names are untrusted. Without bracketed paste (plain sh, many REPLs) a newline in a pasted reference ends the line, so "Insert Reference in Terminal" on a file named x\nrm -rf ~ would have run a command. |
References to paths with control or bidi characters are refused, for the terminal and the clipboard alike (unsafePath). |
@ re-scanned the file on every keystroke. |
The symbol list is computed once per palette opening. |
The landing page gained "A tour of every feature": four chapters (Find your way, Read anything,
Review with git, Work with your agent) with one real screenshot per feature, in the visitor's colour
scheme. The screenshots are reproducible: bun run shots builds a deterministic demo repository
(scripts/demo-repo.ts: neutral authors and dates, a branch ahead of main, uncommitted edits),
serves it with an isolated config directory, HOME and prompt, and drives an offscreen WKWebView
(packaging/macos/appshot.swift) through the steps in scripts/shots.list.ts, cropping each shot to
what it shows. An inventory expert listed every feature from the source first; three reviewers then
checked the result independently: copy accuracy against the code, visual design and accessibility,
and security.
While taking the shots the user reported that JPEGs did not open in the app. The cause was not the
format: in the Mac app every image failed. Images are fetched with the auth header and drawn from a
blob: URL, and the window's content rule list blocked everything but http:// and ws:// on its
own port, so WebKit refused the blob load and the viewer showed "This image could not be decoded".
Browsers (the CLI, Linux) were unaffected, which is why tests and the headless shots never saw it.
| Finding | Fix |
|---|---|
Mac app: every image and every Markdown image failed to load (content rules blocked blob:). |
One more anchored allow rule, ^blob:http://127\.0\.0\.1:<port>/. Only script on that origin can mint such URLs (a sandboxed /api/raw document's blobs are blob:null/…), and navigation to blob: is still cancelled by decidePolicyFor. test/images.test.ts pins the rule set to exactly http, ws and blob:http. Verified with the real rule list compiled into a WKWebView. |
Common formats missing: .jfif, .jpe, .apng, TIFF and HEIC opened as a hex dump. |
Added to IMAGE_MIME and the Markdown IMAGE_EXT (a test keeps the two lists equal). WebKit decodes TIFF/HEIC; elsewhere see the next row. |
| (Security review) A format the window cannot decode, or a damaged file, ended at an error notice with no way to see the bytes. | The image tab falls back to the hex dump under a notice (client.imageview.test.ts). |
(Security review) .svg detection for the source toggle was case-sensitive. |
LOGO.SVG gets the toggle too. |
| (Security review) A Markdown preview loads each image whole; a README pointing at a several-hundred-MB TIFF would buffer it all. | /api/raw?max=N refuses larger files with 413 before reading; previews pass 20 MiB and show the alt text instead. |
(Security review) bun scripts/demo-repo.ts <dir> empties its directory, so a typo could erase a checkout. |
The CLI refuses a directory that is not empty. |
(Security review) The shot server inherited GIT_* (a hook's GIT_DIR would aim the terminal's git at the real repository); Ctrl-C left the server and temp dir behind; the WebView wrote to ~/Library/WebKit. |
GIT_* stripped from its environment, a SIGINT handler cleans up, and the WebView uses a non-persistent data store. |
| (Visual review) Card shots were captured too wide: code showed at about 8 px; the shortcut sheet showed app chrome through its edges; the context menu hid the Explorer letters it was meant to illustrate; crops cut lines in half. | Crops tightened to the content (whole lines, the sheet's first two groups, the menu under the last row); the status bar strip keeps its 1x size and scrolls sideways on phones. |
| (Visual review) Phones downloaded 1600 px images to show them at 300 px. | Every shot also has a half-size variant, served through srcset/sizes; the hero gets fetchpriority="high". |
(Visual review) In a card the image came before its heading for screen readers; separators sat inside <kbd>. |
The h4 comes first in the markup (CSS shows the picture on top); "F4 / ⇧F4" renders as two keys. |
| (Accuracy review) Alt texts said "1 of several", "the two changed lines", and named a letter the menu covered; File History claimed "every commit"; "seven review rounds" was stale. | Alt texts describe what each image shows; "up to 500 commits"; the round count is gone from the copy. Every shortcut and capability on the page was checked against the source. |
Line helpers in the shot steps were off by one: the code view's data-n is 0-based. |
lineEl/clickLine take 1-based line numbers. |
| (Final review) The tour missed features the inventory listed: several terminals and the panel's position, the hex dump, the welcome screen, updates, tabs, zoom and themes. | Four more shots (panels, hexdump, welcome, updates, the last with GitHub's answer stubbed inside the page so the shots never go online) and their copy; README gained a Layout bullet for the rest. The demo repository lives at a fixed /tmp/codeviewer-demo/harbor, because the welcome screen prints the folder's absolute path. |
The Linux app was not affected by the image bug: its Chromium is confined with
--host-resolver-rules and an unreachable --proxy-server, neither of which applies to blob:
URLs, which never reach the network stack. There, TIFF and HEIC (which Chromium cannot decode) now
open as the hex dump under a notice.
An adversarial review (three parallel reviewers plus RED-test validation of the top findings) re-walked the server surface. One finding was validated and fixed; several were refuted or accepted as trade-offs.
| Finding | Fix |
|---|---|
The WebSocket Origin check compared Origin against the request's own Host header, so under a non-loopback --host (where the DNS-rebinding hostOk allowlist is skipped) a page could connect with a self-consistent attacker Host+Origin pair and get 101 Switching Protocols. RED: test/http.wsorigin.security.test.ts. |
Origin is now checked against the server's own origins — loopback names plus every local interface address (os.networkInterfaces()), never anything client-controlled. Deliberate consequence: the origin host must be a literal loopback or interface address; reaching the server via a DNS/mDNS name is rejected, because resolving a client-supplied hostname back to a local address would reopen the rebinding hole. |
(Review) alias.*/pager.* in a hostile repo config were claimed to run commands. |
Refuted by test: git does not expand an alias that shadows a builtin subcommand (test/git.alias.security.test.ts pins this, marker file stays empty). Kept as a regression net. |
(Review) HARDEN overrides travel as -c key=value despite the GIT_CONFIG_KEY_n rule; token in the WS query string; missing-Origin bypass; regex CPU burn in the search worker; pty replay resync edge cases. |
Not exploitable as written (values self-authored; query token is the documented WS mechanism; local DoS only). No change; recorded here for the next pass. |
Agent TUIs redraw constantly, but Claude Code sends little (its fullscreen renderer writes cell diffs; the classic one prints the transcript once, then diffs). The cost was in the client doing more work than the output needed. The terminal now follows these rules:
| Rule | Where |
|---|---|
Paint only rows whose pixels change: each viewport row keeps a snapshot of its cells plus a key (cursor, selection, link hover, reverse video). New pixel state belongs in the key, or calls invalidate(); theme, font, resize and a restored canvas context invalidate. |
Renderer.rowKey, unchanged, remember |
| Hidden terminals (other tabs, closed panel) parse but neither paint nor feed the screen-reader log; showing one repaints it in full. | Renderer.setVisible, TerminalPanel.activate/setShown |
Printable ASCII is copied into the line in runs; anything with per-character rules goes through print(). A seeded fuzz test keeps both paths identical. |
Terminal.printAscii |
| Completed lines are stringified at the end of a write, the newest 200 only; a line redrawn within one write is reported once, as it finally reads. | Terminal.announce, flushAnnounced |
| No layout reads per frame: the scrollbar measures its track after a resize only and writes styles only on change. | TerminalView.updateScrollbar |
Measured on JavaScriptCore: parsing an 8,000-line reprint went from 41 to 21 ms, a spinner tick repaints one row instead of the whole status region, and plain text parses at about 24 MB/s.
A long Claude Code session could come back blank or with a few rows of content. Replaying real Claude Code 2.1.316 through the server reproduced one cause.
| Finding | Fix |
|---|---|
| Full-screen TUIs send cell diffs against what they drew earlier. Once their last full paint has left the 512 KB replay ring, a reattach (page reload, the Mac app reloading after its web process dies, the backpressure resync) rebuilds a mostly blank screen: 0 of 30 rows for real Claude Code with only diffs in the ring. The same-size resize sent on attach is no signal, so nothing repaired it. | PtySession.requestRepaint: after a replay, if the ring was trimmed and the session is in the alternate screen, the PTY is one row shorter for 150 ms, and the program repaints: 30 of 30 rows, identical. The delay matters: two resizes in a row reach Claude Code as no change. Resizes arriving meanwhile are held and applied at the end. The normal screen is left alone (it replays as text, and a resize only reprints a prompt). |
The row skipping of round 11 assumes the canvas keeps its pixels, and repaints after contextrestored. WebKit, so the Mac app, has neither contextlost nor contextrestored (checked in WKWebView): pixels discarded in the background would stay blank except for rows that change. Whether WebKit discards them here was not reproduced; the repaint is cheap either way. |
TerminalView.onReveal calls Renderer.recover() (context settings, then every row) on window focus and when the page becomes visible. |
(Review) resize and write on a killed session throw Terminal is closed while the shell is still dying, and a throw in a WebSocket handler or timer exits the server: every terminal dies. Reproduced; resize already had this. |
PtySession.resizePty, and write catches it too. |
(Review) sendReplay guessed "trimmed" from the byte count, cutting the first line of replays that were complete. |
It uses ringTrimmed. |
| (Review) A reattach at a new size was nudged on top of its real resize. | Nudge only when the size did not change. |
(Review) Not changed. The 150 ms gap is fixed, so a program stalled for longer sees no change and stays as it was. ringTrimmed is never reset, so a TUI started after a trim gets a needless repaint on reattach. Rate-limiting nudges was rejected: each resync resets the screen, so a skipped nudge leaves it blank. |
Open: inline TUIs on the normal screen (Claude Code's classic renderer) and programs that ignore SIGWINCH are still not repaired. Keeping screen state on the server, with Terminal, would cover them without resizing. |
The "Codeviewer X is available" dialog showed only the verify link instead of what changed.
| Finding | Fix |
|---|---|
release.yml created every release with a fixed --notes line, so the release body, which the updater forwards to the dialog, never held the real notes. The summary paragraph lives in the Release X.Y.Z commit. |
scripts/release-notes.ts builds the notes from the release commit (git log -1 --format=%B, no shell string): drops the subject and the Co-Authored-By/Signed-off-by trailers, unwraps hard-wrapped paragraphs (lists, headings, quotes, fences and indented blocks stay as written) and appends the verify line. An empty body warns with ::warning::. The workflow writes the file to $RUNNER_TEMP (not uploaded) and passes --notes-file; commit text never goes through ${{ }}. |
Hard-wrapped paragraphs would break mid-sentence in .upd-notes (white-space: pre-wrap). |
They are unwrapped in the script. |
| The verify line is noise in the dialog (the updater verifies by itself) but useful on the GitHub page. | RELEASE_FOOTER in update.ts is the single definition; Updater.check() strips it before the 4000 cap, so a footer-only body (all older releases) gives empty notes and the dialog shows no box. |
| Fetching the tag's commit at runtime would be a new network request. | Not done (privacy rule). Existing releases keep their old body and are not edited. |
The title bar buttons could look missing. They were drawn, in the previous theme's colour. An
on-screen WKWebView set up like the Mac window reproduced it, and a second theme bug with it.
| Finding | Fix |
|---|---|
.icon-btn has transition: color. A transition started by a theme change does not advance while WebKit is not rendering, so after a switch in a hidden or minimized window the page was already in the new theme and the buttons were not (computed rgb(139, 148, 165), the dark colour, on the light title bar at 2.8:1). The omnibox, sash and other transitioned colours are exposed the same way. |
setDocumentTheme applies a theme under data-theme-switching, which turns all transitions off, and forces a layout so the new styles commit before they return. Buttons match on reveal in all four flips tried, in computed colours and in pixels. |
In the Mac app "system" never followed macOS. WKWebView's prefers-color-scheme follows the window, and the window's appearance was always forced from the page, so "system" read back the page's last choice: light on a Dark Mac after Light, dark on a light Mac from launch. |
The theme message carries the mode, and system sets window.appearance = nil. The matchMedia listener then re-applies the theme with the real value. |
Not checked. takeSnapshot draws web content only and window capture needs Screen Recording permission, so the native toolbar over the title bar was not captured in the light theme. |
None needed so far: the user's screenshot of the dark state matches the snapshot pixels. |
A review of the server routes, the git layer, the terminal sockets, the updater and the client's rendering of untrusted text.
| Finding | Fix |
|---|---|
Security (high). A partial clone (--filter=blob:none) fetches missing objects on demand from its promisor remote, and the repository's own config names the program that does it (remote.origin.uploadpack, core.sshCommand). Rename detection in Changes, blame, File History and /api/git/base?rev= all read blobs, so opening such a folder ran a command (reproduced with git 2.43). protocol.*.allow in a hostile config wins over a protocol.allow override. |
ENV sets GIT_ALLOW_PROTOCOL="", which git checks before any config, so no transport is allowed, and GIT_NO_LAZY_FETCH=1 (git 2.44+). test/git.lazyfetch.test.ts. |
A hostile blame.ignoreRevsFile (a missing file, or /dev/zero) made every blame fail or run to the 30 s timeout. |
blame --no-ignore-revs-file. A -c blame.ignoreRevsFile= override does not work: git sorts the values, so the empty one resets the list before the hostile ones are added (found by the adversarial pass below). A repository's real ignore list is not applied. |
Git.open rejected a viewed folder whose name starts with .. (..cache/) as outside the work tree. |
Only .., ../… or an absolute relative path are outside. |
A PTY text frame of valid non-object JSON (null) threw in the WebSocket handler, which exits the server. |
Non-objects are ignored. |
/ws/pty?cols=80.5 reached the PTY with a fractional size. |
Floored, like resize messages. |
The updater accepted any tag that merely starts with X.Y.Z; the version becomes part of a staging file name (1.2.3/../../x). It still needed a signed checksum line, so this is defence in depth. |
The whole tag must be a semver version. |
| The live index listed a newly created symlink to a file outside the root, which a full rebuild never lists. | FileIndex.apply checks containment like build(). |
| Trojan Source in the main code view and the HEAD peek: bidi overrides reordered the line as drawn, though round 4 (§13) set visible escapes as the rule. Escapes there would shift every DOM offset that native selection and copy rely on. | markBidi wraps each override/isolate in a zero-width inline-block .bidi, drawn as a red bar with the code point in its title. An atomic inline reorders nothing around it; the character stays in the DOM, so selection and copy are unchanged. |
File names, tab titles, breadcrumbs, the commit pill, palette rows, File History details, the status bar root and branch, blame author/summary and terminal titles put bidi overrides and controls on screen raw (invoicefdp.ts). |
showHidden there, as Changes already did. |
Every tab in the diff view, search previews and Markdown code showed as the text \x09. |
showHiddenText (HIDDEN minus the tab) for file contents; names keep the strict rule. |
parseTarget threw on a ⌘-clicked OSC 8 target holding U+2028/U+2029 (. does not match them); codeviewer open had the same regex. |
The s flag. |
esc left ' alone (no single-quoted attribute uses it today). |
Escaped as '. |
Not changed. OSC 52 from a hidden terminal still writes the clipboard, with the notice (the trade-off chosen in round 3, §11). The search test that expects a.txt over its hard link a-hardlink.txt depends on readdir order (the same inode is reported once, under whichever name comes first). |
— |
Five reviewers attacked the HTTP layer, git, the terminal, the client views and the fixes above; each finding below was reproduced before it was fixed.
| Finding | Fix |
|---|---|
Any request whose handler threw got Bun's development error page: HTML, no security headers, the process cwd, file paths and source. Host: [ did it before auth (new URL throws). |
development: false, an error handler returning a JSON 500, and the Host check before the URL is parsed. |
PUT /api/settings capped the request, not the file: indented output grows with the square of the nesting depth (6 KB of [ wrote 18 MB, 40 KB wrote 800 MB, and the server ran out of memory). |
Settings deeper than 32 levels are refused and the serialized file is capped at 1 MB. |
A parent directory swapped for a symlink between resolveSafe and open read a file outside the root (O_NOFOLLOW guards the last component only; reproduced with a swap loop). The search worker re-joined the path the same way. |
openRegular(abs, root) checks the file actually opened (Linux: /proc/self/fd; elsewhere a fresh realpath must reach the same inode). The worker opens the path it checked. Listings are not covered (names only). |
IMAGE_MIME/MIME lookups found Object.prototype keys: z.constructor was served as Content-Type: function Object() {…}. |
Own keys only. |
?limit=1.5 sent content-length: 1.5 and reset the connection. |
Floored. |
/api/open accepted the root, folders and FIFOs. |
Regular files only. |
On POSIX \ was a path separator: a\b opened a/b. |
SEP_RE splits on \ only on Windows. |
| A file created while the index was being built was missed until the next structural change. | apply() during a build invalidates it; a build overtaken by invalidate() walks again (three times at most). |
Driver keys from git config --get-regexp were split on newlines. |
-z. |
Main-thread hangs on untrusted files: the JS lexer's regex-after-word check ran /[A-Za-z_$][\w$]*$/ over the whole prefix per / (10 KB line: 76 s; a 12.6 KB file froze the app 4.6 s); Java/C# and TOML symbol patterns with long space runs (cubic); Markdown headings without the line cap; headingText on [ runs; the YAML key pattern on indented lines; bare-URL ) trimming recounting parentheses. |
A bounded backward scan in the lexer; space runs collapsed before symbol patterns; MAX_LINE for headings; unambiguous patterns; parentheses counted once. test/client.redos.test.ts times each. |
| Terminal output grew one cell without bound: combining marks attach to the previous cell, and REP of a cluster ending in ZWJ joined every repeat into it (800 bytes: a 13M-character cell; 48 KB killed the process). | A cluster holds at most 64 UTF-16 units; REP repeats the base code point only. |
REP amplification: 8 KB of CSI 65535 b scrolled for 12 s on the main thread. |
At most cols × rows repeats (more only overwrite the screen again). |
OSC 8: links.lastIndexOf per link was quadratic (65 000 links: 81 s), targets were unbounded, and after 0xfffe links the ids wrapped, so an earlier link (README) opened the newest target. |
A uri → id map, targets over 2 KB are plain text, and a full table adds no links (ids are never reused) until reset. |
OSC 4 ;1;? repeated a megabyte long sent 200 000 replies into the shell's input. |
At most 256 answers per sequence. |
Replay after the ring trimmed restored the modes from before the first chunk and skipped its head, so a TUI that exited in those bytes came back as alt screen + mouse + paste over a shell prompt. The server's tracker also missed sequences split after 24 bytes, C1 CSI (C2 9B) and : sub-parameters, all of which the client honours. |
applyModes/modesPrefix: the skipped head is applied to the prefix; the carry runs from the last escape (256 bytes at most); C1 and : are parsed (the "latin1" decoder is windows-1252, where 9B is U+203A); a hidden cursor is restored too. |
Deleting the viewed folder exited the server: Bun.spawn throws on a missing cwd, and the status refresh timer's rejection was unhandled (exit 1). It was also the "flaky" PTY replay test: an earlier test's refresh fired after its folder was removed. |
run() reports a failed spawn as a failed git call; the refresh timer catches. |
Not changed: OSC 52 from cat (the notice stays the safeguard); C0 controls inside a CSI are not seen by the server's tracker; an invalid range (bytes=5-3) stays 416 (pinned by a test, allowed by RFC 9110); search previews collapse a tab to one space (nowrap); palette highlights drift when lowercasing changes a string's length (İ); bidi overrides in rendered Markdown prose; in-file Find runs the user's own regex on the main thread; --host localhost in the Chromium app window (the resolver rules map every host name) was not checked. |
— |
| Finding | Fix |
|---|---|
Updater: FileSink.write was not awaited and the hash covered the bytes received, not the bytes written. On a full disk (a 256 KB tmpfs) a truncated CLI binary passed the checksum and replaced the real one. |
Writes are awaited, and the checksum is taken from the file on disk, whose size must match. Reproduced before and after: now ENOSPC fails the update and the old binary stays. |
A tag such as v2.0.0-rc.1 published without the prerelease flag would have been offered to everyone, contradicting SECURITY.md. |
Versions with a pre-release part are never offered. |
| If the new install could not be moved in and the old one not moved back, the staging dir (holding the only copy) was deleted. | It is kept, and the error names it. |
Relaunch after an update dropped --host and --shell. |
Forwarded (--port is not: the old process still holds it). |
Release signing ran in a job whose third-party actions were pinned by mutable tags, and checkout left the job token in .git/config for the build. |
Actions pinned to the commits their tags named (checkout v5.1.0, setup-bun v2.2.0, attest-build-provenance v4.2.2); persist-credentials: false. |
Security (critical). git diff looked inside submodules (it runs git status in each), and a submodule's own config is never read by the overrides: its filter ran from the Changes view. |
diff.ignoreSubmodules=all in HARDEN and --ignore-submodules=all on the diff. |
| Security (critical). The driver listing was capped at 1 MiB and parsed even when cut: a config padded past the cap left the drivers after it armed. | 16 MiB, and a cut or failed listing fails closed: no git call runs. |
Security. The overrides were cached until .git/config changed, but a config can include files the watcher does not see (a tracked file via include.path, ~/.gitconfig): a driver added there later ran. |
The drivers are listed again at the start of each operation (status, changes, blame, history, base, file list). |
diff.renameLimit=0 made status and Changes run into the 30 s timeout on large renames and come back as a clean tree; i18n.logOutputEncoding=UTF-16 broke history, blame and the HEAD line; log.showRoot=false dropped the commit that added a file. |
Rename limits of 1000, UTF-8 log output and --root forced; a timed-out diff is reported as truncated. |
| Search found nothing in a viewed folder that is itself git-ignored (git lists no files there). | An empty git list falls back to the file index. |
A shell that could not start (deleted folder, bad --shell, out of fds) threw in the socket handler and exited the server; sessions were unlimited. |
That terminal gets an exit notice and is closed; at most 64 live shells. |
Deleting files rescanned the whole index per path: a 1 500-file rm froze the server for 20 s on a 200 000-file tree. |
Only a path that was a directory is scanned for children. |
| A file written more often than every 200 ms (a log) held git status back for as long as it was written: every poke restarted the wait. | Throttled: a pending refresh keeps its time. |
| Regressions from the first pass: a capped REP left the cursor elsewhere; heavy-dir events restarted the index build; search reported a match under a symlink instead of its target; tooltips still showed raw names; two doc comments ended up on the wrong code. | REP runs the cap plus the remainder of one row's period; heavy dirs don't invalidate; a link whose target is also listed is skipped; tooltips escaped per line; comments moved. |
| Client: a second jump into a file still loading was undone by the first; reconnecting the events socket resynced nothing; Changes counts went stale on a second edit of a modified file; ⌘B/⌘J did nothing from the terminal on macOS; image tabs never revoked their blob: URLs; Enter confirming an IME composition ran the palette's top item; the change gutter kept an old HEAD base after a discarded fetch. | Each fixed; the reproductions were rerun in Chromium against the build. |
Settings: each window PUT its whole snapshot, so two windows (or two folders) erased each other's keys, the snapshot grew forever (tabs:<root>), and over 64 KB the save on close was dropped (keepalive limit); a layout change in the last 200 ms was lost. |
PATCH /api/settings with only the changed keys, merged on the server in the write queue; the 100 most recent tabs: roots are kept; layout flushes before the save on close. |
Not changed: signing still shares the build job; the index caps file count, not path bytes or directories; the recursive watcher can use the account's whole inotify budget on huge trees; /api/list on a 1M-entry directory takes ~10 s (sort); a repository path containing a newline gets no git integration; OSC 8 links stop for good after 65 534 distinct targets (moving it to its own job is a workflow redesign); codesign --verify without a requirement only proves a consistent signature (releases are ad hoc signed); a Linux update drops a setuid chrome-sandbox; redirects of signed downloads are not host-checked (the signature covers them). |
— |
Auth and origins, filesystem containment, hostile git repositories, the terminal, client rendering, the updater and supply chain, hostile-content denial of service, and the native wrappers. Every finding was reproduced before it was fixed.
| Finding | Fix |
|---|---|
Critical. The compiled server autoloaded bunfig.toml and .env from its working directory, and the Mac app (and codeviewer .) runs it in the viewed folder: a repository's preload ran inside the server; its .env set variables for every shell and git process. Reproduced with a binary built by build.ts. |
--no-compile-autoload-{bunfig,dotenv,tsconfig,package-json}; the Mac app starts the server in /. Pinned by test/invariants.static.test.ts. |
Critical. git diff refreshes the index on stat-dirty files (an extracted tarball) despite GIT_OPTIONAL_LOCKS=0; writing the index ran the repository's own post-index-change hook from the Changes view (and rewrote the index of a repository a .git file pointed at). |
diff.autoRefreshIndex=false and core.hooksPath=/dev/null for every call; stat-only entries are dropped (no numstat line; binaries hash-checked with hash-object --no-filters, which never writes). test/git.hostile-config.test.ts arms every hook and program-naming key and snapshots .git before and after. |
Medium. Any process in a terminal could read the session token from the app window's on-disk profile (History, Favicons, Session Storage) and attach to the other terminals (input injection into a sudo -i tab, verified). |
--incognito; boot page deleted when the page connects; removeWindowFiles() on quit (process.exit skipped the cleanup); the token URL is printed only to a terminal. |
Medium. A .git/commondir, config, HEAD, info/* or objects/info/alternates symlinked to /dev/zero (or a FIFO) made git read until memory ran out. Inherited GIT_DIR/GIT_WORK_TREE pointed git at another repository. |
preflight before git runs and gitDirFilesOk before every operation: those files must be regular and of a sane size, or git is off for the folder. Locator variables are dropped from the run environment. |
| Medium. Search could still read outside the root: a parent swapped for a symlink between the check and the open (259 outside hits in 40 searches), and the root re-resolved per search followed a root replaced by a symlink. FIFOs and devices were opened before their type was checked. | The worker uses the startup root, stats before opening, and checks the opened fd (/proc/self/fd, else inode); openRegular stats first too. |
| Medium. The server's mode tracker (a regex) and the page's parser disagreed on C1 CSI after ESC or inside strings, and on mouse modes: hostile output could turn bracketed paste off in the replay only, so after a reload a pasted command ran line by line. | ModeTracker mirrors the client's parser state machine; test/pty.tracker.diff.test.ts feeds 2000 seeded streams to both and compares the restored modes. |
Medium. The updater replaced whatever looked like an install: a slim binary in ~/.local/bin next to an unrelated chromium/ had the whole folder swapped away; any X.app/Contents/Resources/codeviewer, and any bun runtime not named exactly bun. A concurrent check could change the release apply installed. The API answer and checksum files were read uncapped. |
Package marker files and the bundle identifier are required; only a --compile binary updates; apply installs only the release its own check found, if newer; every read is capped; errors name the asset, not the API's URL. |
Low. WebRTC bypassed the app window's dead proxy (STUN to any host); the Mac relaunch message was honoured at any time from any frame; ln -sf for the command-line tool followed a planted symlink; codeviewer open trusted CODEVIEWER_URL and followed redirects. |
--webrtc-ip-handling-policy=disable_non_proxied_udp; relaunch only from the main frame after the bundle on disk changed; ln -sfh; loopback/own-interface URLs only, redirect: "error". |
Low. The settings temp file had a predictable name and followed symlinks; JSON responses had no cache-control (cached in a --browser profile); any site could detect a running codeviewer by loading its favicon. |
Random name with wx; no-store; Cross-Origin-Resource-Policy: same-origin. |
Low. Terminal: BEL and title floods forced a layout per character (200 KB of BEL froze the page 2.3 s, and again on every reload); legacy mouse reports past column 94 leaked a byte as a keystroke; DECRQM echoed any prefix; a reference to x$(cmd).md pasted at a prompt would run cmd. |
Bell and tab re-render once per batch, no bell while replaying; legacy reports only while they fit in 7 bits; DECRQM answers ""/? only; references with command substitution are refused. |
| Low. An SVG image in a preview, opened via "Open image in new tab" from its blob: URL, ran its script in the app's origin (the token was out of reach only by accident of sessionStorage partitioning). | Outside the Mac app (which cancels blob: navigations) SVGs are drawn from data: URLs, whose documents have an opaque origin. |
Found by the new slow-input matrix: the CSS lexer sliced and regex-tested the rest of the line per identifier (quadratic: 64 KB took 650 ms); headingText rescanned to the end for every unclosed link. |
A bounded scan with the line's last ;/{; stripLinks, a one-pass equivalent of the regex (20 000 random strings agree). |
| DoS (high), my regression from the first pass: a marker element per bidi control (248 000 for 300 lines of U+202E: seconds of layout, gigabytes of memory). | One marker per run, at most 32 per line; past that, visible escapes (never raw controls). |
DoS (high). An SVG of nested <use> (1.6 KB, a million elements) hung the image tab and Markdown previews; a 389 KB PNG declaring 20000×20000 pixels took 2 GB. |
imagecheck.ts before anything is decoded: the <use> reference graph is expanded with a cap of 10 000 (cycles refused), raster headers (PNG, GIF, JPEG, WebP, BMP) limited to 50 Mpx. |
DoS (high). The HEAD peek built every removed line of a hunk (8 MB of $Id$ lines under ident: 1.6 million rows, past 3 GB). |
500 rows of 4000 columns, then "… N more lines". |
DoS (high). Lexing full 10 000-character lines was quadratic for unterminated JS regexes (100 lines of (/[: 10.5 s) and shell ${. The matrix had no multi-character openers. |
Lexing stops at 2000 columns (views colour 1000), regex literals scan 512 characters, ${…} stops at the next {; the matrix gained the opener shapes. |
| DoS (medium). A 6 MB rewrite of distinct lines took 2.4 s to intern before Myers gave up; every cursor move started a blame that was never cancelled (16 left running, 17 s each). | Middles over 200 000 lines are one replaced hunk; a new blame aborts the previous request and the server kills its git process. |
Not changed (recommendations): move the release signing key to a protected GitHub Environment with required reviewers and verify that tags point at main; pin hashes of the Bun runtimes the release cross-compiles with (--compile-executable-path); --signer-workflow in the landing page's attestation command; huge diffs (40 MB of 2-byte lines) still cost git ~1.7 GB within its timeout; Markdown lazy lines under 32 nested quotes copy each line per level (linear, 1.9 s for 1 MB); a 200-column table is ~1 s of layout. |
— |
The rules in AGENTS.md are now checked by tests that cover code written later, not only the bugs
above: invariants.static.test.ts (no dependencies, the only network calls, git only through the
hardened runner, HARDEN literal and complete, tokens compared with tokenOk, no server logging,
reads only through files.ts, Markdown without innerHTML, every client innerHTML interpolation
escaped or reviewed, the exact CSP, compile flags, app window flags), http.routes.test.ts (every
route found in http.ts: 401 without the token for every method, the open-only token reaches only
/api/open, 421 for foreign Hosts, 415 for non-JSON writes, security headers, no 5xx),
redos.matrix.test.ts (every highlighter and symbol language and the Markdown parser against
pathological input, by growth rate), terminal.fuzz.test.ts (seeded hostile output: no throw,
consistent screen, bounded cells, no reply echoing attacker text, nothing while replaying),
files.resolve.fuzz.test.ts (random paths through in/out/loop symlinks never resolve or open
outside), git.hostile-config.test.ts and pty.tracker.diff.test.ts (above).
Release v1.10.7 stopped at its test step on a timeout in the /api/raw shrinking-file test, which
had passed on the same commit in ci. It was not a slow machine: the same test failed 4 runs in 30
on v1.10.6 and about one in four alone, because the server sometimes never ended the response.
| Finding | Fix |
|---|---|
ctl.error right after a chunk could be lost by Bun 1.4.4: the connection stayed open with no end of body, and idleTimeout: 0 never reclaims it, so a client's blob() waited forever |
Wait 50 ms inside pull before the error. Measured against the real file path: no hang with readers up to 400 ms per chunk or under CPU load. Erroring after pull returned, throwing, generator and direct streams, server.timeout and padding chunks all still hung |
| The test ran the race once, so it passed most of the time and blocked a release at random | 25 rounds with a watchdog per round, reported as "hang" |
| Looped at 2 MB, the test once saw a clean end on Linux CI. The server only closes cleanly after reading every byte, so the transfer had probably finished before the truncate, but the test counted no bytes and 1200 more rounds did not repeat it | A 256 MB sparse file that cannot finish first, and byte counts: a full transfer is correct, a short one fails as "short by N" |
The delay is measured, not derived: the cause is inside Bun. The looped test is what will notice if a Bun upgrade changes this.