Add web shell demo: kernel-fs.elf + shell-fs.img as default#19
Merged
Conversation
Brainstormed design for landing visitors of https://cyyeh.github.io/ccc/web/ on a working `$` shell prompt by default — full Phase 3.E + 3.F shell experience (sh + ls/cat/echo/mkdir/rm/edit + ^C cancel) running against shell-fs.img inside the wasm. Snake and hello stay as alternative dropdown options. Pristine disk on every load.
10 tasks (9 implementation + 1 manual smoke test) covering the design spec at docs/superpowers/specs/2026-04-27-web-shell-demo-design.md. TDD-style for block.zig (the one Zig change with automated tests); commit-per-file for the JS/HTML/CSS layers (no browser test infra).
Sibling to the existing disk_file (mmapped host file used by CLI). disk_slice holds a []u8 slice that performTransfer reads/writes via @memcpy. Used by the wasm demo where the disk is fetched into wasm linear memory rather than backed by a file. Slice path takes precedence; both null still yields NoMedia. Adds 4 inline tests: Read, Write, sector-OOB Error, and the slice precedence sanity check. Existing 9 tests + Phase 3 e2e suite unchanged.
…s; add slice RAM-OOB tests Code-review follow-up to db0d49e: - Hoist RAM_BASE: u32 = 0x8000_0000 to a file-level pub const next to BLOCK_BASE / SECTOR_BYTES. Removes the two local copies inside performTransfer and gives Memory + future callers a shared constant. - Move the `sector >= NSECTORS` check above the slice/file dispatch so both backings share the same upper bound. The previous position let the slice branch return before reaching it; harmless in practice (wasm uses a 4 MB slice == NSECTORS * SECTOR_BYTES) but the inline comment mis-claimed it gated both paths. - Add two slice-path RAM-OOB tests covering buffer_pa < RAM_BASE and ram_off past ram.len. Closes a pre-existing gap that the slice path inherited from the file path. 16 tests pass (14 + 2 new). Phase 3 e2e suite unchanged.
Adds a 4 MB disk_buffer alongside the existing elf_buffer, exposed via new diskBufferPtr() / diskBufferCap() exports. runStart gains a third parameter disk_len; when non-zero, the disk_buffer slice is wired into Block.disk_slice so the kernel can read/write the in-memory image. Snake and hello pass disk_len=0 and remain byte-identical on the wire. Shell will pass shell-fs.img.length (~4 MB). New error code -6 for disk_len > DISK_BUFFER_CAP.
Adds two install steps to the wasm build so kernel-fs.elf and shell-fs.img land in zig-out/web/ alongside ccc.wasm, hello.elf, and snake.elf. stage-web.sh copies them into web/ for local dev; gitignore keeps both out of the tree. Both artifacts already build via existing kernel-fs and shell-fs-img steps; this just wires them into the wasm step's install list.
The Stage Pages artifact step had a per-file allowlist (ccc.wasm + hello.elf + snake.elf) that silently dropped any new wasm artifact installed by build.zig. Task 3 of the web-shell-demo plan added kernel-fs.elf + shell-fs.img — without this fix the deployed shell demo would 404 on both. Replace the three explicit cp lines with cp -r zig-out/web/. _site/web/ mirroring the existing cp -r web/. _site/web/ idiom one line up. Future artifacts wired into wasm_step ship automatically.
Worker now fetches the ELF and (optionally) a disk image in parallel via Promise.all, copies both into wasm linear memory, and calls runStart(elfLen, trace, diskLen). Snake/hello have no diskUrl and pass diskLen=0 — byte-identical to the previous behavior. Disk-too-large yields a halt with descriptive error (parallel to the existing ELF-too-large path).
The fixed-grid model previously clamped row at H-1 on \n, which silently overwrote the bottom line — invisible at 32×16 with snake (which redraws the whole screen each tick) but immediately fatal for the shell, which streams output line-by-line and expects a scrolling terminal. _lineFeed() shifts the screen up by one line and clears the bottom row when already at the last row; called from both the \n branch and (via the existing CSI H clamp) for paranoid bounds on cursor positioning.
The previous comment claimed _lineFeed was used by "any cursor positioning that would otherwise place the cursor past the last row," but the CSI H handler at line 122 clamps inline without calling _lineFeed (and cursor positioning shouldn't scroll anyway — it should teleport). Fix the comment to match what the code actually does; flag IND / NEL as the natural future callers if reverse line feed ever lands.
- Terminal grid bumps from 32×16 to 80×24 to fit the shell + editor. - New ELF entry kernel-fs.elf at index "2"; new DISK_URLS table maps index "2" to shell-fs.img. - Per-program key handling: snake keeps its 6-key whitelist; shell gets full ASCII printables + Ctrl+letter (0x01..0x1a covering ^C/^D/^U/ ^S/^X) + Enter/Backspace/Tab/Esc + 3-byte ESC arrow sequences for the editor; hello takes no input. - Backspace sends 0x7f (kernel/console.zig accepts both 0x08 and 0x7f, picking the standard DEL). - Cmd/Alt always pass through so browser shortcuts (Cmd+R, Cmd+T) keep working; only e.ctrlKey is intercepted. - updateProgramInstructions toggles the new shell-instructions card. - startCurrent forwards diskUrl through to the Worker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dropdown now shows shell (selected) | snake | hello. New shell-instructions card sits above the snake card and lists the canonical try-commands (ls /bin, cat /etc/motd, edit /etc/motd, ^C, exit) plus a "what's running" line that ties the demo back to the CLI build. Same device-warning row as snake (mobile/tablet can't drive per-byte input).
- height (480px fixed) → min-height (600px), enough for 24 rows at the existing 15px / 1.55 line-height. Overflow stays auto as a safety net for any future program emitting more than 24 rows. - white-space changes from pre-wrap to pre: the ANSI interpreter already manages line breaks at the 80-col boundary; letting the browser re-wrap was fine at 32×16 (snake never overflowed) but breaks shell output that assumes the grid model. Horizontal scroll appears on narrow viewports. - Typography unchanged (15px / 1.55) — snake's render is unaffected.
- web/README.md: shell.elf added as the default program; "How it works" gets one paragraph about the disk_buffer plumbing; gitignore mention bumps from three artifacts to five. - top-level README.md: "Live demo" line lists shell (default) / snake / hello with one-clause descriptions of each. No changes to architecture or status sections — Phase 3 is already documented end-to-end.
Code-review follow-up to 800e09e (Task 9). Step 2's wasm-exports list still showed the old 2-arg runStart and didn't mention diskBufferPtr/Cap; step 3's new disk-buffer paragraph references both, so step 2 read as out-of-date next to it. Also update stage-web.sh's inline comment to match the surrounding "five artifacts" prose.
The wasm hardcoded 16 MB of guest RAM, which was fine for snake/hello (both M-mode-only programs that fit in a few KB) but broke kernel-fs.elf: the kernel's trampoline page is mapped at RAM_BASE + 128 MB - 4 KB (0x87FFF000), and any access there triggered a load/store fault during kmain's page-table setup. The fault bubbled M-modeward, the M-mode trap handler wrote 0xFF to the halt MMIO, and the wasm halted with exit 255 before the kernel ever printed anything to UART. Bump RAM_SIZE to 128 MB to match src/emulator/main.zig's default (--memory 128). The CLI hadn't hit this because it always defaults to 128 MB; the snake-demo plan author punted on RAM size as "plenty for hello.elf" in 2026-04. Caught by Task 10 manual smoke test. Confirmed via Node-based smoke test against ccc.wasm + kernel-fs.elf + shell-fs.img: with 128 MB RAM the kernel boots and prints '$ ' within ~10M instructions (≤1s of wasm setTimeout chunks at 50 000/chunk).
Uart.pushRx asserts PLIC src 10 only when the FIFO was previously empty,
gated behind self.plic ?*Plic = null. The CLI sets uart.plic = &plic in
src/emulator/main.zig:180, but the wasm path never did, so browser
keystrokes landed in the FIFO without ever raising the IRQ.
Result: $ prompt printed (kernel boot was unaffected), but typing did
nothing — the kernel's UART driver never woke up and the cooked-mode
console never echoed. Caught by Task 10 manual smoke test.
Confirmed via Node smoke test: pushing 'ls\n' now produces echoed input
+ directory listing + new prompt ('$ ls\n.\n..\nbin\netc\ntmp\n$ ').
Two fixes for "standard CLI behavior" gaps surfaced by Task 10: 1. Backspace was silently dropped. The kernel emits the canonical '\b \b' sequence (ESC=0x08, space, ESC=0x08) to visually erase a character, but ansi.js had a blanket "if (b < 0x20) return" guard in GROUND state that ate every C0 control. Add an explicit 0x08 handler that moves the cursor left one column (clamped at 0); the trailing space + second backspace then write/walk-over the cell correctly via the existing _writeCell path. 2. No visible cursor. Standard terminals show a blinking block at the current input column. Added a CSS-animated <span id="cursor"> in a new .terminal-wrapper next to <pre id="output">; demo.js positions it on each render via inline `top: calc(20px + ansi.row * 1.55em)` and `left: calc(24px + ansi.col * 1ch)`. Animation: opacity steps between 0.7 and 0 every 0.5s. Also wires ESC[?25l/h to a new ansi.cursor_visible flag (was an explicit no-op before). The editor enters/exits raw mode by toggling this; demo.js's render() hides/shows the cursor accordingly. Verified against the Node smoke test: still echoes 'ls\n' correctly through the cooked-mode kernel; backspace now reaches ansi.js as 0x08 instead of being filtered out at the worker boundary.
The kernel takes ~1s of wasm-chunked stepping to boot to its first \$ prompt. Until then the screen was blank with a blinking cursor — looking like the program was waiting for input when it was actually still initialising. Add a `booting` flag set in startCurrent() and cleared on the first "output" message from the worker. While true, render() shows "waiting..." and hides the cursor. First output byte → flip the flag, feed bytes to ANSI, render normally. Halt handler also clears `booting` so a program that halts before emitting any output (e.g. wasm runStart returning non-zero) doesn't get stuck on the placeholder.
The kernel writes bare \n to UART (no \r\n translation in console.zig),
relying on the host tty's ONLCR cooked-mode flag to convert it to \r\n
on display. The CLI gets that translation for free; the browser ANSI
interpreter does not, so 'ls' output rendered as a staircase:
$ ls
.
..
bin
etc
tmp
$
Update _lineFeed to reset col to 0 after the row advance, matching what
ONLCR would do. Snake is unaffected: it explicitly emits "\r\n" in
programs/snake/snake.zig. The editor uses ESC[r;cH for positioning and
never relies on LF for column reset.
Two UX papercuts surfaced by Task 10: 1. Slow command response. CHUNK was 50K instructions per setTimeout(0) tick (≈4ms scheduling overhead per yield), giving roughly 12 MIPS effective throughput. Fine for snake (8 Hz tick), but the shell burns most cycles on wfi-spin idleness and disk-fetch latency, so booting + running a single 'ls' was several seconds. Bump to 500K per chunk → ~30-100ms per chunk → ~10× more progress between yields. Input latency stays ≤ 100ms (invisible for typing). 2. Output looked double-spaced. line-height was 1.55 (web prose default), much taller than typical terminal-emulator spacing (macOS Terminal ≈ 1.2). Drop pre.output to line-height: 1.2 and match the cursor span's height + position math in CSS and demo.js so the blinking block still sits exactly on the rendered glyph row. min-height drops from 600px to 480px to match the new 24-row content height (24 × 15px × 1.2 ≈ 432px + 40px padding).
Standard ls(1) lays directory entries into terminal-width columns; ours
was printing one entry per line, which looked unidiomatic in the web
demo's 80×24 terminal:
$ ls
.
..
bin
etc
tmp
$
Drop the per-entry newline in printName, gate a leading space on the
first/not-first flag, and emit a single trailing newline at the end of
each directory listing:
$ ls
. .. bin etc tmp
$
Also bumps tests/e2e/shell.zig's "sh\n" landmark to " sh " to match the
new format ('sh' surrounded by spaces in the joined listing). The full
Phase 3 e2e suite (e2e-shell e2e-editor e2e-persist e2e-cancel e2e-fs)
still passes.
A more complete ls would lay out into terminal-width columns; this is
the right shape for the small filesystem this shell ships against.
Add an "editor:" row to the shell-instructions card that lists the keys edit.zig actually responds to (arrows, Backspace, ^S save, ^X exit) plus an explicit Ctrl-not-Cmd reminder for macOS users (Cmd+S is the browser's "Save Page" — we only intercept Ctrl, so Cmd-anything passes straight to the browser). Surfaced by a smoke-test question of the form "how do I save?".
Two UX improvements surfaced by Task 10 manual testing:
1. ^S used to save silently — no feedback. Add a showStatus() helper
that writes "-- saved N bytes to <path> --" at row 24 (bottom of
the 80×24 grid) and restores the cursor to the editing offset. The
status is visible until the next keystroke triggers a redraw, which
is exactly the right model: long enough to read, gone the moment
the user resumes editing. Save failure shows "-- save failed: <path> --".
2. ^X used to leave the editor's full-screen redraw on the terminal,
so the shell's next prompt landed below the file contents. Add a
`defer writeStr("\x1b[2J\x1b[H")` so any exit path (^X or read
failure) clears the screen and homes the cursor before the shell's
wait4 returns and reprints "$ ". Now exiting the editor lands on a
fresh blank terminal with just the prompt.
e2e-editor and e2e-persist still pass.
New docs/references/shell-execution.md walks through the full under-the-hood path: browser keystroke → demo.js per-program key map → Worker pushInput → wasm UART RX FIFO → PLIC src 10 → S-trap → console.feedByte → cooked-mode line buffer → sh's read returns → fork+exec /bin/ls → child writes to fd 1 → UART → output_buf → consumeOutput → ansi.js → 80×24 grid. Mirrors the structure of docs/references/snake-execution.md — big-picture diagram, layer-by- layer breakdown, file map, build steps, and a snake-vs-shell comparison table. web/index.html's shell-instructions card now has the same "how it works" row snake's card has, linking out to the new doc.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
shell.elfselected by default and a working$prompt within ~1s. The full Phase 3.E + 3.F shell experience runs entirely in the browser:ls /bin,cat /etc/motd,echo hi > /tmp/x,edit /etc/motd(raw-mode editor with arrows,^Ssave,^Xexit),^Cto cancel,exitto halt.disk_len=0; the disk surface is opt-in perrunStart).disk_buffer; refresh = clean slate.What's in the branch
Spec + plan (
docs/superpowers/{specs,plans}/2026-04-27-web-shell-demo-*.md) — brainstormed and reviewed before any code landed.Emulator —
src/emulator/devices/block.ziggrew adisk_slice: ?[]u8field for slice-backed disks (CLI keepsdisk_file, wasm uses the slice). 6 new inline tests;RAM_BASEhoisted to a file-levelpub const;sector >= NSECTORScheck moved to gate both backing paths.Wasm —
demo/web_main.ziggrew a 4 MBdisk_buffer+diskBufferPtr/Capexports +runStart(elf_len, trace, disk_len)signature. BumpedRAM_SIZEfrom 16 MB → 128 MB (kernel's trampoline lives at 128 MB - 4 KB; previously caused a fault during boot). Wiredstate_storage.uart.plic = &state_storage.plicsopushInputactually raises PLIC src 10.Browser —
web/runner.jsfetches ELF + disk in parallel, calls 3-argrunStart.web/demo.jsbumps to 80×24, adds per-program key map (shell: ASCII printables + Ctrl+letter + Enter/Backspace/Tab/Esc + 3-byte ESC arrows), forwardsdiskUrl, showswaiting...placeholder before first byte, blinking block cursor positioned by(ansi.row, ansi.col).web/ansi.jsadds\b(backspace), scroll-on-newline, ONLCR (LF→LF+CR — the host TTY does this for the CLI), and trackscursor_visiblefromESC[?25l/h.web/index.htmlreorders the dropdown (shell selected) and adds a parallel shell-instructions card.web/demo.cssgrows the output panel and tightens line-height to 1.2 (terminal-like).Build + CI —
build.ziginstallskernel-fs.elf+shell-fs.imgintozig-out/web/.scripts/stage-web.shcopies all 5 artifacts..gitignorecovers them..github/workflows/pages.ymlswitched from per-file allowlist tocp -r zig-out/web/. _site/web/(future-proof for the next wasm artifact).Userland fixes from smoke testing —
lsnow prints space-separated entries on one line (was one-per-line).edit ^Snow shows-- saved N bytes to <path> --at row 24;^Xclears the screen so the shell's next prompt lands on a fresh terminal.web/runner.jschunk size bumped from 50 K → 500 K instructions persetTimeout(0)for ~10× snappier shell response.Docs — new
docs/references/shell-execution.mdwalks through all 7 layers (browser → Worker → wasm → boot → kernel → FS → userspace → cooked-mode console + editor) plus a snake-vs-shell comparison table; linked from the shell-instructions card.web/README.mddocumentsshell.elfas the default + the disk-buffer plumbing. Top-levelREADME.md"Live demo" line lists all three programs.Test plan
Automated:
zig build test— all unit + kernel host tests pass (16 inlineblock.zigtests including 6 new ones)zig build e2e-shell e2e-editor e2e-persist e2e-cancel e2e-fs— full Phase 3 e2e suite greenzig build wasm— producesccc.wasm(~30 KB) +kernel-fs.elf+shell-fs.img+hello.elf+snake.elfinzig-out/web/Manual browser smoke test (verified locally):
shell.elfselected;waiting...shown until first byte$prompt appears within ~1s with blinking cursorls→. .. bin etc tmp(one line);ls /binlists all 9 binariescat /etc/motd→hello from phase 3echo hi > /tmp/xthencat /tmp/x→hiedit /etc/motd: arrows move, type,^Sshows-- saved N bytes to /etc/motd --,^Xclears screen and returns to$catthen^C→^Cechoed + new$snake.elf: WASD/Space/Q work; switch back toshell.elf→ fresh disk (edits don't persist)🤖 Generated with Claude Code