Skip to content

Add web shell demo: kernel-fs.elf + shell-fs.img as default#19

Merged
cyyeh merged 25 commits into
mainfrom
web-shell-demo
Apr 27, 2026
Merged

Add web shell demo: kernel-fs.elf + shell-fs.img as default#19
cyyeh merged 25 commits into
mainfrom
web-shell-demo

Conversation

@cyyeh
Copy link
Copy Markdown
Owner

@cyyeh cyyeh commented Apr 27, 2026

Summary

  • Visiting https://cyyeh.github.io/ccc/web/ now lands on shell.elf selected 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, ^S save, ^X exit), ^C to cancel, exit to halt.
  • Snake and hello stay in the dropdown as alternative options. They're byte-identical on the wire (snake/hello pass disk_len=0; the disk surface is opt-in per runStart).
  • Disk is pristine on every page load — writes mutate an in-wasm 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.

Emulatorsrc/emulator/devices/block.zig grew a disk_slice: ?[]u8 field for slice-backed disks (CLI keeps disk_file, wasm uses the slice). 6 new inline tests; RAM_BASE hoisted to a file-level pub const; sector >= NSECTORS check moved to gate both backing paths.

Wasmdemo/web_main.zig grew a 4 MB disk_buffer + diskBufferPtr/Cap exports + runStart(elf_len, trace, disk_len) signature. Bumped RAM_SIZE from 16 MB → 128 MB (kernel's trampoline lives at 128 MB - 4 KB; previously caused a fault during boot). Wired state_storage.uart.plic = &state_storage.plic so pushInput actually raises PLIC src 10.

Browserweb/runner.js fetches ELF + disk in parallel, calls 3-arg runStart. web/demo.js bumps to 80×24, adds per-program key map (shell: ASCII printables + Ctrl+letter + Enter/Backspace/Tab/Esc + 3-byte ESC arrows), forwards diskUrl, shows waiting... placeholder before first byte, blinking block cursor positioned by (ansi.row, ansi.col). web/ansi.js adds \b (backspace), scroll-on-newline, ONLCR (LF→LF+CR — the host TTY does this for the CLI), and tracks cursor_visible from ESC[?25l/h. web/index.html reorders the dropdown (shell selected) and adds a parallel shell-instructions card. web/demo.css grows the output panel and tightens line-height to 1.2 (terminal-like).

Build + CIbuild.zig installs kernel-fs.elf + shell-fs.img into zig-out/web/. scripts/stage-web.sh copies all 5 artifacts. .gitignore covers them. .github/workflows/pages.yml switched from per-file allowlist to cp -r zig-out/web/. _site/web/ (future-proof for the next wasm artifact).

Userland fixes from smoke testingls now prints space-separated entries on one line (was one-per-line). edit ^S now shows -- saved N bytes to <path> -- at row 24; ^X clears the screen so the shell's next prompt lands on a fresh terminal. web/runner.js chunk size bumped from 50 K → 500 K instructions per setTimeout(0) for ~10× snappier shell response.

Docs — new docs/references/shell-execution.md walks 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.md documents shell.elf as the default + the disk-buffer plumbing. Top-level README.md "Live demo" line lists all three programs.

Test plan

Automated:

  • zig build test — all unit + kernel host tests pass (16 inline block.zig tests including 6 new ones)
  • zig build e2e-shell e2e-editor e2e-persist e2e-cancel e2e-fs — full Phase 3 e2e suite green
  • zig build wasm — produces ccc.wasm (~30 KB) + kernel-fs.elf + shell-fs.img + hello.elf + snake.elf in zig-out/web/

Manual browser smoke test (verified locally):

  • Page loads on shell.elf selected; waiting... shown until first byte
  • $ prompt appears within ~1s with blinking cursor
  • ls. .. bin etc tmp (one line); ls /bin lists all 9 binaries
  • cat /etc/motdhello from phase 3
  • echo hi > /tmp/x then cat /tmp/xhi
  • edit /etc/motd: arrows move, type, ^S shows -- saved N bytes to /etc/motd --, ^X clears screen and returns to $
  • cat then ^C^C echoed + new $
  • Backspace visually erases the character before the cursor
  • Switch to snake.elf: WASD/Space/Q work; switch back to shell.elf → fresh disk (edits don't persist)
  • Hard refresh: pristine disk

🤖 Generated with Claude Code

cyyeh and others added 25 commits April 27, 2026 14:42
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.
@cyyeh cyyeh merged commit 9734ca7 into main Apr 27, 2026
3 checks passed
@cyyeh cyyeh deleted the web-shell-demo branch April 27, 2026 09:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant