{"id":50999889,"url":"https://github.com/cablehead/ghostty-web-nu","last_synced_at":"2026-06-20T13:30:52.938Z","repository":{"id":359029165,"uuid":"1241302359","full_name":"cablehead/ghostty-web-nu","owner":"cablehead","description":"ghostty-web served by http-nu via /pty/* endpoints","archived":false,"fork":false,"pushed_at":"2026-05-25T15:05:24.000Z","size":2508,"stargazers_count":5,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-18T21:31:41.847Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"HTML","has_issues":false,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/cablehead.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-17T07:55:44.000Z","updated_at":"2026-06-06T07:37:20.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/cablehead/ghostty-web-nu","commit_stats":null,"previous_names":["cablehead/ghostty-web-nu"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/cablehead/ghostty-web-nu","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cablehead%2Fghostty-web-nu","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cablehead%2Fghostty-web-nu/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cablehead%2Fghostty-web-nu/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cablehead%2Fghostty-web-nu/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/cablehead","download_url":"https://codeload.github.com/cablehead/ghostty-web-nu/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cablehead%2Fghostty-web-nu/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34572423,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-20T02:00:06.407Z","response_time":98,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-20T13:30:49.708Z","updated_at":"2026-06-20T13:30:52.931Z","avatar_url":"https://github.com/cablehead.png","language":"HTML","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ghostty-web-nu\n\nThe [Ghostty](https://github.com/ghostty-org/ghostty) VT100 emulator\n([ghostty-web](https://github.com/coder/ghostty-web)) in a browser tab,\ntalking to an embedded [Nushell](https://www.nushell.sh) REPL over SSE.\n[http-nu](https://github.com/cablehead/http-nu) bridges the browser to a\npty and runs the REPL; a server-side [wezterm-term](https://github.com/wez/wezterm)\nstands in for the browser terminal -- tracking screen + scrollback so a\nreattach replays clean, and answering VT queries from the pty when no\nclient is attached.\n\n\u003cimg width=\"1509\" height=\"986\" alt=\"image\" src=\"https://github.com/user-attachments/assets/7d37d200-e78c-4f9a-ba2e-8296f6c838d1\" /\u003e\n\n## Run\n\nYou need the `pty-wezterm-term` branch of http-nu -- it ships the `pty`\ncommand set and the `repl` subcommand that the embedded path execs into.\n\n```sh\ngit clone --branch pty-wezterm-term https://github.com/cablehead/http-nu.git\ncd http-nu \u0026\u0026 cargo build --release\n```\n\nThen in this repo:\n\n```sh\ngit clone https://github.com/cablehead/ghostty-web-nu.git\ncd ghostty-web-nu\n```\n\nThe three ghostty-web runtime files (`ghostty-web.js`, `ghostty-vt.wasm`,\n`__vite-browser-external-*.js`) are vendored into `www/` so a fresh clone runs\nwithout `npm install`. See `www/VENDORED.md` for how to refresh them when you\nwant a newer ghostty-web release.\n\nSingle-pane:\n\n```sh\n/path/to/http-nu :5002 ./serve.nu --watch\n```\n\nMulti-session 2-pane:\n\n```sh\n/path/to/http-nu --datastar :5003 ./serve-sessions.nu --watch\n```\n\nOpen `http://localhost:500{2,3}`. Each browser tab gets its own embedded `nu`\nREPL.\n\n`GHOSTTY_WEB_NU_CMD=bash` (or any other command) bypasses the embedded path\nand execs the named binary instead -- useful for isolating \"is this nu vs\nsomething else\" while debugging.\n\n## Two surfaces\n\n| File | What it serves |\n| --- | --- |\n| `serve.nu` + `www/index.html` | Single-pane terminal. Whole canvas is one REPL. |\n| `serve-sessions.nu` + `www/sessions.html` | Multi-pane. Sidebar of sessions, focused pane on the right. Tabs sort by most-recent input; a hard refresh lands on the tab you were last typing in. A server-wide title sits in the topbar; the focused pane's `cols x rows` mirrors into the status footer. |\n\nBoth pages use the same `/pty/*` endpoints; the sessions surface adds\n`/sse` and `/nav` on top for the projected session list.\n\nSessions keymap:\n\n| Key | Action |\n| --- | --- |\n| `Alt+T` | New session |\n| `Alt+D` | Close current session |\n| `Alt+J` / `Alt+K` | Cycle to next / previous |\n| `Alt+R` | Rename current tab (stored as pty meta) |\n| `Alt+Shift+R` | Rename the window title (persisted in `stor`) |\n| `Cmd+C` | Copy current selection (ghostty-web's auto-copy-on-mouseup is disabled) |\n| `` Cmd+` `` | Falls through to the OS window switcher |\n\n## How it hangs together\n\n```mermaid\nflowchart LR\n    Browser[\"browser tab\u003cbr\u003eghostty-web\u003cbr\u003eWASM + Canvas\"]\n    HttpNu[\"http-nu\u003cbr\u003eserve.nu /\u003cbr\u003eserve-sessions.nu\"]\n    Parser[\"wezterm-term\u003cbr\u003e(server-side virtual\u003cbr\u003escreen + scrollback)\"]\n    Pty[\"pty pair\u003cbr\u003emaster \u0026lt;-\u0026gt; slave\"]\n    Nu[\"embedded nushell\u003cbr\u003ehttp-nu repl\"]\n\n    Browser -- \"POST /pty/input raw\u003cbr\u003e(one POST per keystroke)\" --\u003e HttpNu\n    Browser -- \"POST /pty/resize {cols, rows}\" --\u003e HttpNu\n    HttpNu -- \"GET /pty/stream SSE base64\u003cbr\u003e(scrollback snapshot first, then live tail)\" --\u003e Browser\n\n    HttpNu -- \"pty open: openpty + fork+exec /proc/self/exe repl\" --\u003e Nu\n    HttpNu -- \"write input / ioctl TIOCSWINSZ\" --\u003e Pty\n    Pty -- \"read output\" --\u003e HttpNu\n    HttpNu -- \"feed terminal, broadcast bytes\" --\u003e Parser\n    Pty -- \"stdin / stdout / SIGWINCH\" --\u003e Nu\n```\n\nThe pty session map lives in the http-nu process. Each `sid` keeps a\n`portable_pty::MasterPty` + `Child` + a `wezterm_term::Terminal` (canonical\nvirtual screen + scrollback) + a single-slot output sender (see below).\n\n### Endpoints\n\nSingle-pane (`serve.nu`):\n\n- `POST /pty/create` -- `{cols, rows}` body, returns `{sid}`.\n- `POST /pty/input?sid=` -- raw body written verbatim to pty master. Served\n  by a Rust fast-path in http-nu (no nu handler invocation); bumps the\n  session's `last_input_ms` and emits a `touched` `pty.events` entry when\n  the bump moves a sid to the head of the list.\n- `POST /pty/resize?sid=` -- `{cols, rows}` -\u003e `master.resize()` + SIGWINCH.\n- `GET  /pty/stream?sid=` -- SSE: first frame is a snapshot of the full\n  scrollback + visible screen, serialized as canonical VT bytes with SGR\n  colors and attributes preserved. Subsequent frames are live pty output.\n  Each frame: `data: \u003cbase64\u003e\\n\\n`.\n\nSessions (`serve-sessions.nu`) adds:\n\n- `GET  /sse` -- projected UX state. Bootstraps the session list, then\n  emits `datastar-patch-elements` and `datastar-patch-signals` deltas in\n  response to `pty.events` (created / died / resized / meta / touched) and\n  `nav.events` (this conn's current selection). The selected sid is also\n  patched into the `$selectedSid` signal so visibility-driven SSE reconnects\n  land on the same tab.\n- `POST /nav` -- publishes `{connId, sid}` to `nav.events`.\n- `POST /pty/new` -- spawn a fresh session and select it for the requesting\n  conn (`pty open --embedded` by default; honors `GHOSTTY_WEB_NU_CMD`).\n- `POST /pty/close?sid=` -- kill child, drop master, publish `deleted`.\n\n## Server-side terminal state\n\nThe server tees every pty read into a `wezterm_term::Terminal` and into a\nsingle output channel:\n\n- **Last-attach-wins.** A new `/pty/stream` attach replaces whatever sender\n  was installed; the previous SSE drains and closes. There's at most one\n  active consumer per sid. (Reattach UX: open the same URL again, the older\n  tab quietly stops receiving.)\n- **Scrollback snapshot on attach.** Before live bytes start flowing, the\n  server walks the terminal's scrollback + visible screen and emits a\n  canonical VT byte sequence (SGR colors + attributes preserved) that\n  reproduces both history and the current cursor. An attach mid-session\n  lands clean: no stale DSR queries, no partial sequences, no out-of-order\n  mode toggles, and the user can scroll back into the history that\n  predates them.\n- **Scrollback cap surfaced to the client.** http-nu caps wezterm-term's\n  scrollback (via `TerminalConfiguration`) and exposes the limit as\n  `$HTTP_NU.pty_scrollback_lines`. `serve-sessions.nu` threads it into the\n  page so the browser-side `Terminal({scrollback})` matches what the\n  server actually retains.\n- **Initial size sync.** On attach, the browser pushes `/pty/resize` with\n  its current term dimensions, so the pty matches the visible viewport from\n  byte zero rather than running at the open-time 80x24.\n- **DA1/DA2 fallback when no subscriber.** If the program emits a device-\n  attributes query (`ESC[c`, `ESC[\u003ec`) while no client is attached, the\n  reader thread injects the canonical reply back into the pty stdin so the\n  program doesn't block on a reply that has nowhere to come from. Mirrors\n  zmx's [`respondToDeviceAttributes`](https://github.com/neurosnap/zmx/blob/main/src/util.zig).\n  When a client is attached, the browser's own VT emulator answers and the\n  server stays out of the way.\n- **Direct per-keystroke input.** `term.onData` POSTs each chunk straight\n  to `/pty/input`. The Rust fast-path makes the round-trip cheap enough on\n  localhost that no coalescing earns its added latency. A trailing-edge\n  20ms coalescer (`sendInputCoalesced` in `sessions.html`) is kept dormant\n  alongside the direct path for A/B feel-testing.\n- **Scroll preservation.** ghostty-web's `Terminal.write()` force-scrolls\n  to the bottom on every call. `sessions.html` wraps it to keep your\n  position when you've scrolled up; new output appends silently below the\n  viewport until you scroll back to the bottom.\n\n### Debug dump\n\n`HTTP_NU_PTY_DUMP=/tmp/pty.log` on the server makes the reader thread\nappend each pty chunk in escaped form (`\\e`, `\\r`, `\\n`, `\\xNN`) to that\nfile. Useful for pinning down \"did nu actually emit those bytes\" type\nquestions without disturbing the live browser.\n\n## Embedded REPL\n\n`pty open --embedded` execs `/proc/self/exe` with the `repl` subcommand --\nhttp-nu re-runs itself rather than resolving its binary by name, so\nswapping the binary during a dev rebuild doesn't strand existing children.\nThe REPL inherits all of http-nu's custom commands (`.append`, `.cat`,\n`.static`, `.mj`, `pty open`, ...) at a real nu prompt, no separate `nu`\nbinary needed.\n\nThe bootstrap in the `repl` subcommand (in http-nu's `src/main.rs`):\n\n1. Ignores SIGTTOU/SIGTTIN/SIGTSTP/SIGQUIT so spawned externals can\n   `tcsetpgrp` cleanly.\n2. Loads the nushell standard library (`std`, `std-rfc/*`).\n3. Evaluates the bundled default env, runs `nu_engine::convert_env_values`\n   so `$env.PATH` is a real list (and `ENV_CONVERSIONS` is wired up), then\n   layers `~/.config/nushell/env.nu` and `~/.config/nushell/config.nu` on\n   top -- the same order stock nushell uses.\n4. Restores `nu_cli::Print` (http-nu's `PrintCommand` shadows it and routes\n   to the http log; not what you want at an interactive REPL).\n5. Hands off to `nu_cli::evaluate_repl`.\n\nEarlier versions used a `fork+evaluate_repl` (no-exec) path. That worked\nfor built-ins but silently dropped output from external commands (`^ls`,\n`^cat`, ...) because http-nu's multi-threaded Rust runtime broke nushell's\n`tcsetpgrp` dance on spawn. The self-re-exec path sidesteps it.\n\n## Resize gotchas\n\nA live window drag fires `window.resize` per animation frame, and\n`fit.observeResize()` reacts to each one with a synchronous WASM-buffer +\ncanvas reallocation. Left on, the browser tab freezes. Both `index.html`\nand `sessions.html` skip `fit.observeResize()` and use a hard-debounced\n`window.resize` listener instead.\n\n## Test\n\n```sh\ncd test\nnode resize-browser.mjs       # chromium, hammer resizes, check responsive\nENGINE=webkit node resize-browser.mjs\n```\n\nThe test boots its own isolated http-nu instance on a random port and\ndrives a real browser through the same SSE/fetch path the user does.\n\n## License\n\nMIT.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcablehead%2Fghostty-web-nu","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcablehead%2Fghostty-web-nu","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcablehead%2Fghostty-web-nu/lists"}