diff --git a/CLAUDE.md b/CLAUDE.md index 4f3edb1..63f5b95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -**Planning stage — no code yet.** The `docs/` folder holds the authoritative spec; there is no `src/`, `public/`, or `package.json` yet. Read these before implementing: +**Built and working — v0.1 (core) + v0.2 (multi-tab) + v0.3 (Claude Code cockpit) shipped.** `src/` (TypeScript backend), `public/` (esbuild frontend), and tests all exist; 212 tests pass. v0.3 work happens on the `v0.3-cockpit` branch. Key docs: -- `docs/TECH_DOC.md` — design & rationale (the **why**). Authoritative for protocol (§4), session model (§5.2), security (§7), acceptance criteria (§8). -- `docs/ARCHITECTURE.md` — development guide (the **how**). Module contracts at **function-signature level**, dependency rules, build order (§6 S1–S8), and the invariant checklist (§8). -- `docs/PLAN.md` — the task plan. Dependency-ordered **waves W0–W5**, fine-grained **tasks T1–T21** for multi-agent parallelism (each task has `Owns:` / `Depends:`; per-task model & isolation in §4). -- `docs/PROGRESS_LOG.md` — the **memory file** (cross-session). Orchestrator-owned; subagents return their entry rather than writing it. See workflow below. -- `.claude/agents/` — project subagents: `module-builder` (TDD-implements one task), `module-reviewer` (read-only review). +- `docs/TECH_DOC.md` — design & rationale (the **why**). Protocol (§4), session model (§5.2), security (§7), acceptance (§8). +- `docs/ARCHITECTURE.md` — function-signature-level contracts, dependency rules, the **invariant checklist (§8)**. On conflict, ARCHITECTURE wins on *how*; the M*/L* anchors are cross-validated fixes. +- `docs/PROGRESS_LOG.md` — the **memory file** (cross-session). Read it first; it's the running record of what shipped (v0.1 T1–T21, then v0.2/v0.3 feature entries). Orchestrator-owned. +- `docs/PLAN.md` (v0.1 task plan) and `~/.claude/plans/shimmering-wondering-island.md` (v0.3 cockpit plan). +- `.claude/agents/` — project subagents: `module-builder`, `module-reviewer` (for parallel dev). + +**v0.3 additions** (cockpit): Claude Code **hooks → live status** per tab (`POST /hook`, `npm run setup-hooks`); **remote approve/reject** (held `POST /hook/permission`); **tmux keepalive** (`USE_TMUX`); plus FE: themes, search, dashboard, QR, PWA, new-tab-in-cwd. The server gained a hook side-channel but the terminal stream is still a byte-shuttle. **Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*. @@ -61,7 +63,7 @@ npm start # listens on 0.0.0.0:3000 npm test # unit tests (vitest, all modules) ``` -Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1. +Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1. ## Architecture (the parts that span files) diff --git a/README.md b/README.md index 58beb41..47e365a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,15 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi > ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security](#security). +## Features + +- **Multi-tab** — each tab is an independent shell session. Tabs auto-name to the current folder (double-click to rename), show a connection dot (🟢/🟡/🔴) and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory. +- **Claude Code cockpit** (with hooks, see below) — each tab shows Claude's status (⚙ working / ⏳ needs approval / ✓ idle), sends a browser notification when it needs you, and lets you **tap Approve / Reject** on a tool request with no typing. +- **tmux keepalive** (optional) — run the shell inside tmux so sessions survive a server/host restart, not just a disconnect. +- **Mobile + desktop shortcut bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / Tab / `/` (the Claude Code keys a phone keyboard can't produce). +- **Session keepalive + replay** — the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer. +- **Toolbar** — 🔍 scrollback search · ⚙ themes & font size · ▦ all-sessions dashboard · 📱 QR connect (scan to open on another device). Clickable links. Installable as a PWA. + ## Requirements - Node.js ≥ 18 (developed on v24) @@ -34,6 +43,23 @@ ipconfig getifaddr en0 For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`. +## Claude Code cockpit (optional) + +To see Claude's status per tab and approve/reject tool calls from your phone, install the hooks once: + +```bash +npm run setup-hooks # adds http hooks to ~/.claude/settings.json (backs it up) +# npm run setup-hooks -- --remove # to uninstall +``` + +Then run `claude` inside a tab. The hooks POST to the server (loopback only) when Claude starts/stops/needs permission; the tab badge updates live, and a `PermissionRequest` shows an **Approve / Reject** bar. The hooks are a no-op outside web-terminal (they curl `$WEBTERM_HOOK_URL`, which is only set in spawned shells), so they're safe to leave installed. + +**tmux keepalive** — run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart: + +```bash +USE_TMUX=1 npm start +``` + ## Configuration All via environment variables (no hardcoding): @@ -46,6 +72,7 @@ All via environment variables (no hardcoding): | `IDLE_TTL` | `86400` (s) | reclaim a detached session after this idle time | | `SCROLLBACK_BYTES` | `2097152` | per-session replay ring buffer (bytes) | | `MAX_PAYLOAD_BYTES` | `1048576` | max single WS frame | +| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH | | `ALLOWED_ORIGINS` | (derived) | extra allowed WS origins, comma-separated | `allowedOrigins` is **derived from the host's network-interface IPs** (plus `localhost` and anything in `ALLOWED_ORIGINS`) — never from `BIND_HOST`, since `0.0.0.0` is never a real browser Origin. diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 8b8c12e..56e37b3 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,7 +24,12 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 -- **当前阶段**: **v0.2 进行中** —— 多标签 + 标题 + 移动端 Claude Code 快捷键栏(✅ 已做,见下方 v0.2 条目)。v0.1 见下。 +- **当前阶段**: **v0.3 cockpit 完成**(分支 `v0.3-cockpit`,待合并 main)。计划见 `~/.claude/plans/shimmering-wondering-island.md`。 + - ✅ Step1 前端快赢(键栏/搜索/链接/QR/PWA) · ✅ H2/H4 状态感知+通知 · ✅ H3 远程批准/拒绝 · ✅ H1 tmux 保活 · ✅ M3/M7/M6(主题/仪表盘/同目录新标签)。 + - ⬜ 仅剩可选项:**O1 token 认证**(默认关)、**O2 历史会话浏览**(读 ~/.claude/projects JSONL → --resume)。 + - **212 测试全绿**;README/CLAUDE.md 已更新;工具栏 🔍⚙▦📱。 + - **下一步**: 合并 `v0.3-cockpit` → main;O1/O2 作为可选后续。 + - v0.2/v0.1 历史见下方条目。 - **Wave**: **W0–W5 基本完成**。v0.1 功能完整、测试与真机浏览器验证通过。仅剩 2 项需用户真设备。 - **进度**: **20/21 任务**(T19 待真手机)· **187 自动化测试全绿** · 真 PTY 端到端验证 · **真浏览器 smoke 通过**。 - **验收 F1–F9**: ✅ F1/F2(浏览器 smoke:渲染+输入输出+全彩)· ✅ F3(resize 无错)· ⬜ **F4 需局域网真机** · ✅ F5/F6(集成 ⑤⑥ 回放 CJK/ANSI)· ⬜ **F7 需真手机**(软键盘栏)· ✅ F8(浏览器 exit→重连提示)· ✅ F9(集成+curl 401)。 @@ -121,6 +126,63 @@ - **遗留 / 待办**: 无(原前端打包待决项已解决,见下补记)。 - **commit**: `b126cf0` +### 2026-06-18 · v0.3 Step 5 — 中价值 M3/M7/M6(主题/仪表盘/同目录新标签) + +- **状态**: `[x]` DONE。真浏览器验证通过。212 测试全绿。 +- **M3 主题/字体**(commit `f79f14b`):`settings.ts`(⚙ 面板:dark/light/solarized + 字号 A−/A+),localStorage 持久化;`terminal-session.applyTheme`;TabApp.applySettings 应用到所有+新标签。验证:light 主题渲染+持久化。 +- **M7 仪表盘**(commit `f79f14b`):`dashboard.ts`(▦ 浮层列出所有标签:连接点+文件夹+Claude 状态;点行切换;打开时每秒刷新);TabApp.snapshot()/focusTab()。验证:列出 2 标签、点击切换。 +- **M6 同目录新标签**(commit `87e1173`):`title-util.cwdFromOsc7`(解析 OSC 7 cwd)+4 单测;terminal-session 注册 OSC7 handler→cwd getter,新会话 attach 带 cwd;protocol/types attach.cwd(绝对路径);session/manager/server 透传到 spawn cwd;tabs `+` 在当前 tab 的 cwd 开新标签(无则回 home)。验证:server attach.cwd 生效;`cd /private/tmp`→`+`→新标签 pwd=/private/tmp(此 zsh 发 OSC7)。 +- **工具栏**: 🔍搜索 · ⚙设置 · ▦仪表盘 · 📱QR。 +- **commit**: `f79f14b`(M3+M7)、`87e1173`(M6) + +### 2026-06-17 · v0.3 Step 4 — H1 tmux 保活(服务重启不丢会话) + +- **状态**: `[x]` DONE。**真 tmux 集成测试验证通过**(commit `9099f73`)。**至此 H1–H4 全部高价值功能完成。** +- **机制**: useTmux 时把 shell 跑进 `tmux new-session -A -s web_`;node-pty 进程只是 tmux **客户端**。服务重启后,客户端带同 sessionId 重连 → manager 发现 `web_` tmux 会话仍在 → 用同 id 重建客户端(`-A` 附着)→ 接回原 shell。 +- **改动**: 新增 `src/session/tmux.ts`(同步 CLI 封装 available/has/kill,best-effort);config `useTmux`(env `USE_TMUX` 1/0/auto→检测 PATH);session createSession 加可选 id(重连用)+ tmux 分支 spawn + `Session.tmuxName` + kill 走 `tmux kill-session`;manager handleAttach 加 Case 3.5(重启后重附)+ shutdown 对 tmux 会话**只杀客户端 pty 不杀 tmux**(保活)。 +- **测试**: 共享集成 cfg 设 `USE_TMUX:0`(防 tmux 泄漏);单测 CFG `useTmux:false`;集成 `H1`(真 tmux,gate=tmux+PTY):设 shell 变量 → **重启 server** → 同 id 重连 → 变量仍在(`MARK-tmuxlives`)。208 测试全绿,tsc 通过。 +- **注**: 当前后台 dev server(:3000)是 H3 之前启动的、不含 H1;要试 tmux 保活需 `USE_TMUX=1 npm start`。 +- **commit**: `9099f73` + +### 2026-06-17 · v0.3 Step 3 — H3 远程批准/拒绝(不打字) + +- **状态**: `[x]` DONE。**端到端真浏览器验证通过**(commit `9a6150c`)。 +- **机制**: `PermissionRequest` hook 用 curl POST 到 `/hook/permission` 并**阻塞等响应**;服务端长挂该 HTTP 响应,推 `status{waiting,pending:true}` 给前端 → 前端显示 **[✓Approve][✗Reject]** 横幅 → 点按发 ws `approve`/`reject` → 服务端用 `{hookSpecificOutput.decision.behavior:allow|deny}` 回应被挂的请求 → curl stdout 把决定交给 Claude。**全程不打字**。 +- **兜底**: 5 分钟超时 / 断开即释放 → 回空 `{}`,Claude 回退到自己的交互提示;web-terminal 外 curl 无 URL → no-op。 +- **改动**: types(ClientMessage approve/reject、status.pending)、protocol(白名单+解析)、manager(pending 透传)、server(`/hook/permission` 长挂 + approve/reject 路由 + resolvePending)、setup-hooks(PermissionRequest 用 held 命令)、FE(terminal-session approve()/reject()+pending、tabs 批准横幅)。 +- **验证**: 207 测试全绿(协议 approve/reject 单测 + 集成 ⑧ held→approve→allow);浏览器实测横幅"Claude wants to use Bash"→Approve→held 请求得到 allow→横幅消失(截图 /tmp/v03-approve.png)。 +- **commit**: `9a6150c` + +### 2026-06-17 · v0.3 Step 2 — H2/H4 Claude 状态感知(headline) + +- **状态**: `[x]` DONE(分支 `v0.3-cockpit`)。**端到端真浏览器验证通过**。 +- **机制**: Claude Code hooks → 服务端 `/hook` 侧信道 → WS `status` 帧 → 标签角标实时更新。架构演进:服务端获得 hook 侧信道(终端流仍是 byte-shuttle)。 +- **H2 服务端**(commit `a411c89`): + - types:`ClaudeStatus` + ServerMessage `status`;Session.claudeStatus;manager.handleHookEvent。 + - session:spawn env 注入 `WEBTERM_SESSION`(哪个标签)+ `WEBTERM_HOOK_URL`(本机 /hook)。 + - `src/http/hook.ts` 纯映射(PreToolUse→working、PermissionRequest/permission_prompt→waiting、Stop/idle_prompt→idle);6 单测。 + - server:`POST /hook`(仅 loopback,express.json 64kb)→ 推 status。**顺带修真 bug**:`closeIdleConnections()` 让 hook keep-alive 空闲连接不再卡住 close()/SIGTERM。 + - 集成 ⑦(真 PTY):attach → POST /hook → ws 收到 status(注意测试要在 fetch 前挂监听,推送是同步的)。 +- **H4 前端**(commit `c81cebd`): + - terminal-session 处理 status → claudeStatus + onClaudeStatus。 + - tabs:每标签 Claude 角标(⚙工作/⏳等批准/✓空闲);**非活动 + waiting 标签加琥珀高亮 + 浏览器通知**(首次切换请求权限)。 + - `scripts/setup-hooks.mjs` + `npm run setup-hooks`:幂等装/卸 ~/.claude/settings.json 的 command hook(内联 curl,web-terminal 外是 no-op),自动备份。 +- **验证**: 205 测试全绿;浏览器实测 PermissionRequest→⏳、PreToolUse→⚙、Stop→✓;非活动 waiting 标签琥珀+⏳(截图 /tmp/v03-status.png)。0 console error。 +- **commit**: `a411c89`(H2 服务端)、`c81cebd`(H4 前端+脚本) + +### 2026-06-17 · v0.3 Step 1 — 前端快赢(键栏/搜索/链接/QR/PWA) + +- **状态**: `[x]` DONE(分支 `v0.3-cockpit`;纯前端,无服务端改动) +- **计划**: `~/.claude/plans/shimmering-wondering-island.md`(v0.3 cockpit,已批准)。 +- **H4-keybar**: 键栏加 Claude 快捷键 Esc·Esc/^O/^T/^B + 每键 tooltip(KEY_MAP 14 键;commit `eaeacf3`)。 +- **M1 搜索**: `@xterm/addon-search` 每终端 + 🔍 工具栏按钮 + 搜索框(Enter 下一个/Shift+Enter 上一个/Esc 关)。 +- **M2 可点击链接**: `@xterm/addon-web-links`。 +- **结构**: 标签栏拆成 `#tabs`(可滚)+ `#toolbar`(工具按钮区),为后续 QR/设置/仪表盘复用。 +- **M5 QR**: 📱 按钮 → 客户端渲染 location.origin 的二维码(经 LAN IP 打开即可分享)+ localhost 提示(commit `fbc218c`)。 +- **M4 PWA**: manifest + icon.svg + sw.js(network-first,不拦 /term//hook)+ 注册;可安装。 +- **验证(真浏览器)**: 14 键栏按钮、搜索高亮命中、🔍📱 工具按钮、SW controlled、QR canvas 渲染、0 console error。199 测试全绿、双 tsc、build 通过。截图 /tmp/v03-quickwins.png、/tmp/v03-qr.png。 +- **commit**: `eaeacf3`、`fbc218c` + ### 2026-06-17 · v0.2 修复 — 切换标签要点多次 - **状态**: `[x]` FIXED diff --git a/package-lock.json b/package-lock.json index 2ff190d..529cd14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,18 @@ "hasInstallScript": true, "dependencies": { "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "express": "^5.2.1", "node-pty": "^1.1.0", + "qrcode": "^1.5.4", "ws": "^8.21.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "esbuild": "^0.28.1", "tsx": "^4.22.4", @@ -910,6 +914,16 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -1074,6 +1088,18 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -1096,6 +1122,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1181,6 +1231,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1191,6 +1250,35 @@ "node": ">=18" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1255,6 +1343,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1274,6 +1371,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1294,6 +1397,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1499,6 +1608,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1541,6 +1663,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1665,6 +1796,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1932,6 +2072,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2094,6 +2246,42 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2103,6 +2291,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -2140,6 +2337,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2182,6 +2388,23 @@ "node": ">= 0.10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2221,6 +2444,21 @@ "node": ">= 0.10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -2322,6 +2560,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2440,6 +2684,32 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2758,6 +3028,12 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2775,6 +3051,20 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2801,6 +3091,47 @@ "optional": true } } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index 61574b6..b90c750 100644 --- a/package.json +++ b/package.json @@ -16,18 +16,23 @@ "dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "setup-hooks": "node scripts/setup-hooks.mjs" }, "dependencies": { "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "express": "^5.2.1", "node-pty": "^1.1.0", + "qrcode": "^1.5.4", "ws": "^8.21.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "esbuild": "^0.28.1", "tsx": "^4.22.4", diff --git a/public/dashboard.ts b/public/dashboard.ts new file mode 100644 index 0000000..65af614 --- /dev/null +++ b/public/dashboard.ts @@ -0,0 +1,94 @@ +/** + * public/dashboard.ts — all-sessions overview (M7). + * + * A ▦ toolbar button opens an overlay listing every tab with its connection + * dot, folder name, and Claude status; click a row to focus that tab. While + * open it re-renders every second so statuses stay live. + */ + +export interface TabInfo { + idx: number + title: string + conn: string // SessionStatus + claude: string // ClaudeStatus + active: boolean + pending: boolean +} + +export interface DashboardHooks { + snapshot: () => TabInfo[] + focus: (idx: number) => void +} + +function claudeText(claude: string): string { + if (claude === 'working') return '⚙ working' + if (claude === 'waiting') return '⏳ needs approval' + if (claude === 'idle') return '✓ idle' + return '' +} + +export function mountDashboard(toolbar: HTMLElement, hooks: DashboardHooks): void { + const overlay = document.createElement('div') + overlay.id = 'dashboard' + overlay.style.display = 'none' + + const card = document.createElement('div') + card.className = 'dash-card' + overlay.appendChild(card) + document.body.appendChild(overlay) + + let timer: ReturnType | null = null + + const render = (): void => { + card.replaceChildren() + const title = document.createElement('div') + title.className = 'dash-title' + title.textContent = 'Sessions' + card.appendChild(title) + + for (const t of hooks.snapshot()) { + const row = document.createElement('div') + row.className = t.active ? 'dash-row active' : 'dash-row' + + const dot = document.createElement('span') + dot.className = `tab-dot dot-${t.conn}` + const name = document.createElement('span') + name.className = 'dash-name' + name.textContent = t.title + const claude = document.createElement('span') + claude.className = t.pending ? 'dash-claude pending' : 'dash-claude' + claude.textContent = claudeText(t.claude) + + row.append(dot, name, claude) + row.addEventListener('click', () => { + hooks.focus(t.idx) + hide() + }) + card.appendChild(row) + } + } + + const open = (): void => { + render() + overlay.style.display = 'flex' + timer = setInterval(render, 1000) + } + const hide = (): void => { + overlay.style.display = 'none' + if (timer !== null) { + clearInterval(timer) + timer = null + } + } + overlay.addEventListener('click', (e) => { + if (e.target === overlay) hide() + }) + + const btn = document.createElement('button') + btn.className = 'toolbtn' + btn.textContent = '▦' + btn.title = 'All sessions' + btn.setAttribute('aria-label', 'All sessions') + btn.addEventListener('click', () => (overlay.style.display === 'none' ? open() : hide())) + toolbar.appendChild(btn) +} diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..d0af5c0 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/index.html b/public/index.html index f5ef295..df675fe 100644 --- a/public/index.html +++ b/public/index.html @@ -2,13 +2,21 @@ - + Web Terminal + + + + + -
+
+
+
+
diff --git a/public/keybar.ts b/public/keybar.ts index 2fa408a..747b579 100644 --- a/public/keybar.ts +++ b/public/keybar.ts @@ -1,19 +1,22 @@ /** - * public/keybar.ts — Mobile touch key bar, tuned for Claude Code. + * public/keybar.ts — Mobile/desktop touch key bar, tuned for Claude Code. * - * Exports pure key-to-byte mappings and a mount function that binds DOM buttons - * to onSend on touchstart (bypassing xterm so the soft keyboard doesn't pop up). + * Buttons send terminal byte sequences a phone soft keyboard can't easily + * produce, most-used first. Order/selection follows the official Claude Code + * keyboard-shortcut docs (interactive-mode / keybindings). * - * Order/selection follows Claude Code mobile habits — the keys a phone soft - * keyboard can't easily produce, most-used first (leftmost = no scroll needed): - * Esc \x1b interrupt Claude / dismiss (primary — most used) - * Shift+Tab \x1b[Z cycle plan / auto-accept mode - * ↑ ↓ \x1b[A/B select menu option / history - * Enter \r confirm - * Ctrl+C \x03 cancel / quit - * Tab \t complete / toggle - * ← → \x1b[D/C cursor - * / / slash-command launcher + * Esc \x1b interrupt Claude / dismiss (primary) + * Esc·Esc \x1b\x1b rewind / clear draft + * Shift+Tab \x1b[Z cycle plan / auto-accept mode + * ↑ ↓ \x1b[A/B select menu option / history + * Enter \r confirm + * Ctrl+C \x03 cancel / quit + * Ctrl+O \x0f expand transcript / tool detail + * Ctrl+T \x14 toggle task list + * Ctrl+B \x02 background running task + * Tab \t complete / toggle + * ← → \x1b[D/C cursor + * / / slash-command launcher */ import type { MountKeybar } from '../src/types.js' @@ -21,6 +24,7 @@ import type { MountKeybar } from '../src/types.js' /** Key name → byte string mapping (pure data, for testing). */ export const KEY_MAP = { esc: '\x1b', + escEsc: '\x1b\x1b', shiftTab: '\x1b[Z', arrowUp: '\x1b[A', arrowDown: '\x1b[B', @@ -28,24 +32,38 @@ export const KEY_MAP = { arrowRight: '\x1b[C', enter: '\r', ctrlC: '\x03', + ctrlO: '\x0f', + ctrlT: '\x14', + ctrlB: '\x02', tab: '\t', slash: '/', } as const export type KeyName = keyof typeof KEY_MAP -/** Button layout: most-used Claude Code keys first. `primary` styles a key as prominent. */ -export const KEYBAR_BUTTONS: Array<{ label: string; keyName: KeyName; primary?: boolean }> = [ - { label: 'Esc', keyName: 'esc', primary: true }, - { label: '⇧Tab', keyName: 'shiftTab' }, - { label: '↑', keyName: 'arrowUp' }, - { label: '↓', keyName: 'arrowDown' }, - { label: '⏎', keyName: 'enter' }, - { label: '^C', keyName: 'ctrlC' }, - { label: 'Tab', keyName: 'tab' }, - { label: '←', keyName: 'arrowLeft' }, - { label: '→', keyName: 'arrowRight' }, - { label: '/', keyName: 'slash' }, +interface KeybarButton { + label: string + keyName: KeyName + title: string // tooltip / aria-label + primary?: boolean // styled as prominent +} + +/** Button layout: most-used Claude Code keys first. */ +export const KEYBAR_BUTTONS: KeybarButton[] = [ + { label: 'Esc', keyName: 'esc', title: 'Esc — interrupt Claude / dismiss', primary: true }, + { label: 'Esc²', keyName: 'escEsc', title: 'Esc Esc — rewind / clear draft' }, + { label: '⇧Tab', keyName: 'shiftTab', title: 'Shift+Tab — cycle plan / auto-accept mode' }, + { label: '↑', keyName: 'arrowUp', title: 'Up — previous option / history' }, + { label: '↓', keyName: 'arrowDown', title: 'Down — next option / history' }, + { label: '⏎', keyName: 'enter', title: 'Enter — confirm' }, + { label: '^C', keyName: 'ctrlC', title: 'Ctrl+C — cancel / quit' }, + { label: '^O', keyName: 'ctrlO', title: 'Ctrl+O — expand transcript / tool detail' }, + { label: '^T', keyName: 'ctrlT', title: 'Ctrl+T — toggle task list' }, + { label: '^B', keyName: 'ctrlB', title: 'Ctrl+B — background running task' }, + { label: 'Tab', keyName: 'tab', title: 'Tab — complete / toggle' }, + { label: '←', keyName: 'arrowLeft', title: 'Left' }, + { label: '→', keyName: 'arrowRight', title: 'Right' }, + { label: '/', keyName: 'slash', title: 'Slash — command launcher' }, ] /** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */ @@ -53,13 +71,14 @@ export const mountKeybar: MountKeybar = (onSend) => { const keybarEl = document.getElementById('keybar') if (!keybarEl) return - for (const { label, keyName, primary } of KEYBAR_BUTTONS) { + for (const { label, keyName, title, primary } of KEYBAR_BUTTONS) { const btn = document.createElement('button') btn.textContent = label btn.classList.add('keybar-btn') if (primary) btn.classList.add('keybar-btn-primary') btn.dataset.key = keyName - btn.setAttribute('aria-label', label) + btn.title = title + btn.setAttribute('aria-label', title) const bytes = KEY_MAP[keyName] diff --git a/public/main.ts b/public/main.ts index c65ac05..0361269 100644 --- a/public/main.ts +++ b/public/main.ts @@ -1,27 +1,63 @@ /** * public/main.ts — esbuild entry point for the browser frontend. * - * Bootstraps the multi-tab app: a tab bar (#tabbar) over a stack of terminal - * panes (#term), each tab an independent TerminalSession (own WS + server - * session). The mobile key bar routes input to the active tab. + * Bootstraps the multi-tab app: a tab bar (#tabs) + a utility toolbar (#toolbar) + * over a stack of terminal panes (#term), each tab an independent TerminalSession. + * The mobile key bar routes input to the active tab. * * Per-session terminal/WS/reconnect logic lives in terminal-session.ts; * tab management + persistence in tabs.ts. */ -// CSS side-effect import processed by esbuild (→ public/build/main.css). -// tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic. -// @ts-ignore +// @ts-ignore CSS side-effect import processed by esbuild (→ public/build/main.css). import '@xterm/xterm/css/xterm.css' import { mountKeybar } from './keybar.js' import { TabApp } from './tabs.js' +import { mountSearch } from './search.js' +import { mountQrConnect } from './qr.js' +import { mountSettings, loadSettings, type Settings } from './settings.js' +import { mountDashboard } from './dashboard.js' const paneHost = document.getElementById('term') -const tabBar = document.getElementById('tabbar') +const tabs = document.getElementById('tabs') +const toolbar = document.getElementById('toolbar') if (!paneHost) throw new Error('#term element not found in DOM') -if (!tabBar) throw new Error('#tabbar element not found in DOM') +if (!tabs) throw new Error('#tabs element not found in DOM') +if (!toolbar) throw new Error('#toolbar element not found in DOM') -const app = new TabApp(paneHost, tabBar) +const app = new TabApp(paneHost, tabs) -// Key bar (mobile) sends to whichever tab is active. +// Apply saved theme/font (M3) to the restored tabs. +let settings: Settings = loadSettings() +app.applySettings(settings) + +// Key bar (mobile + desktop) sends to whichever tab is active. mountKeybar((data) => app.sendToActive(data)) + +// Toolbar utilities. +mountSearch(toolbar, { + find: (query, dir) => app.findInActive(query, dir), + clear: () => app.clearActiveSearch(), +}) +mountSettings( + toolbar, + () => settings, + (s) => { + settings = s + app.applySettings(s) + }, +) +mountDashboard(toolbar, { + snapshot: () => app.snapshot(), + focus: (idx) => app.focusTab(idx), +}) +mountQrConnect(toolbar) + +// PWA: register the service worker (installable + offline shell, M4). +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + void navigator.serviceWorker.register('./sw.js').catch(() => { + // SW registration is best-effort; the app works without it. + }) + }) +} diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..d84b8c0 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Web Terminal", + "short_name": "WebTerm", + "description": "Drive your shell / Claude Code from any device on your LAN.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "any", + "background_color": "#1a1a1a", + "theme_color": "#1a1a1a", + "icons": [ + { + "src": "./icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/public/qr.ts b/public/qr.ts new file mode 100644 index 0000000..7995cea --- /dev/null +++ b/public/qr.ts @@ -0,0 +1,63 @@ +/** + * public/qr.ts — "open on another device" QR modal (M5). + * + * Renders a QR of the current origin (client-side, no server call). When the + * page is opened via the host's LAN IP, the QR is directly shareable to a + * phone/tablet on the same network. + */ + +import QRCode from 'qrcode' + +export function mountQrConnect(toolbar: HTMLElement): void { + const modal = document.createElement('div') + modal.id = 'qrmodal' + modal.style.display = 'none' + + const card = document.createElement('div') + card.className = 'qr-card' + + const title = document.createElement('div') + title.className = 'qr-title' + title.textContent = 'Open on another device' + + const canvas = document.createElement('canvas') + const urlEl = document.createElement('div') + urlEl.className = 'qr-url' + const note = document.createElement('div') + note.className = 'qr-note' + + const close = document.createElement('button') + close.className = 'qr-close' + close.textContent = 'Close' + + card.append(title, canvas, urlEl, note, close) + modal.appendChild(card) + document.body.appendChild(modal) + + const open = (): void => { + const origin = location.origin + urlEl.textContent = origin + void QRCode.toCanvas(canvas, origin, { width: 220, margin: 1 }) + const host = location.hostname + note.textContent = + host === 'localhost' || host === '127.0.0.1' + ? 'Tip: open this page via your LAN IP (not localhost) for a shareable code.' + : 'Scan on a device on the same network.' + modal.style.display = 'flex' + } + const hide = (): void => { + modal.style.display = 'none' + } + modal.addEventListener('click', (e) => { + if (e.target === modal) hide() + }) + close.addEventListener('click', hide) + + const toggle = document.createElement('button') + toggle.className = 'toolbtn' + toggle.textContent = '📱' + toggle.title = 'Connect another device (QR)' + toggle.setAttribute('aria-label', 'Connect another device') + toggle.addEventListener('click', open) + toolbar.appendChild(toggle) +} diff --git a/public/search.ts b/public/search.ts new file mode 100644 index 0000000..a3fb9aa --- /dev/null +++ b/public/search.ts @@ -0,0 +1,65 @@ +/** + * public/search.ts — scrollback search box for the active terminal (M1). + * + * Adds a 🔍 button to the toolbar that toggles a small search overlay. + * Enter = next, Shift+Enter = previous, Esc = close. + */ + +export interface SearchHooks { + find: (query: string, dir: 'next' | 'prev') => void + clear: () => void +} + +function makeBtn(label: string, title: string): HTMLButtonElement { + const b = document.createElement('button') + b.textContent = label + b.title = title + b.setAttribute('aria-label', title) + return b +} + +export function mountSearch(toolbar: HTMLElement, hooks: SearchHooks): void { + const box = document.createElement('div') + box.id = 'searchbox' + box.style.display = 'none' + + const input = document.createElement('input') + input.type = 'text' + input.className = 'search-input' + input.placeholder = 'Search scrollback…' + + const prev = makeBtn('↑', 'Previous match (Shift+Enter)') + const next = makeBtn('↓', 'Next match (Enter)') + const close = makeBtn('×', 'Close (Esc)') + box.append(input, prev, next, close) + document.body.appendChild(box) + + const open = (): void => { + box.style.display = 'flex' + input.focus() + input.select() + } + const hide = (): void => { + box.style.display = 'none' + hooks.clear() + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + hooks.find(input.value, e.shiftKey ? 'prev' : 'next') + } else if (e.key === 'Escape') { + e.preventDefault() + hide() + } + e.stopPropagation() + }) + prev.addEventListener('click', () => hooks.find(input.value, 'prev')) + next.addEventListener('click', () => hooks.find(input.value, 'next')) + close.addEventListener('click', hide) + + const toggle = makeBtn('🔍', 'Search scrollback') + toggle.className = 'toolbtn' + toggle.addEventListener('click', () => (box.style.display === 'none' ? open() : hide())) + toolbar.appendChild(toggle) +} diff --git a/public/settings.ts b/public/settings.ts new file mode 100644 index 0000000..7337b17 --- /dev/null +++ b/public/settings.ts @@ -0,0 +1,128 @@ +/** + * public/settings.ts — theme + font-size settings (M3). + * + * A ⚙ toolbar button opens a small panel; choices persist to localStorage and + * apply to every terminal via the onChange callback. + */ + +import type { ITheme } from '@xterm/xterm' + +export interface Settings { + theme: string + fontSize: number +} + +export const THEMES: Record = { + dark: { background: '#1a1a1a', foreground: '#e8e8e8', cursor: '#e8e8e8' }, + light: { + background: '#f5f5f5', + foreground: '#1a1a1a', + cursor: '#1a1a1a', + selectionBackground: '#bcd3f5', + }, + solarized: { background: '#002b36', foreground: '#93a1a1', cursor: '#93a1a1' }, +} + +const KEY = 'web-terminal:settings' +export const DEFAULT_SETTINGS: Settings = { theme: 'dark', fontSize: 14 } + +export function loadSettings(): Settings { + try { + const raw = localStorage.getItem(KEY) + if (raw) { + const s = JSON.parse(raw) as Partial + return { + theme: typeof s.theme === 'string' && THEMES[s.theme] ? s.theme : DEFAULT_SETTINGS.theme, + fontSize: + typeof s.fontSize === 'number' && s.fontSize >= 10 && s.fontSize <= 24 + ? s.fontSize + : DEFAULT_SETTINGS.fontSize, + } + } + } catch { + // ignore + } + return { ...DEFAULT_SETTINGS } +} + +function saveSettings(s: Settings): void { + try { + localStorage.setItem(KEY, JSON.stringify(s)) + } catch { + // ignore + } +} + +export function mountSettings( + toolbar: HTMLElement, + current: () => Settings, + onChange: (s: Settings) => void, +): void { + const panel = document.createElement('div') + panel.id = 'settingspanel' + panel.style.display = 'none' + + const apply = (next: Settings): void => { + saveSettings(next) + onChange(next) + render() + } + + function render(): void { + const s = current() + panel.replaceChildren() + + const themeRow = document.createElement('div') + themeRow.className = 'settings-row' + themeRow.append(label('Theme')) + for (const key of Object.keys(THEMES)) { + const b = document.createElement('button') + b.textContent = key + b.className = key === s.theme ? 'settings-opt active' : 'settings-opt' + b.addEventListener('click', () => apply({ ...s, theme: key })) + themeRow.appendChild(b) + } + + const fontRow = document.createElement('div') + fontRow.className = 'settings-row' + fontRow.append(label('Font')) + const minus = document.createElement('button') + minus.className = 'settings-opt' + minus.textContent = 'A−' + minus.addEventListener('click', () => apply({ ...s, fontSize: Math.max(10, s.fontSize - 1) })) + const size = document.createElement('span') + size.className = 'settings-size' + size.textContent = String(s.fontSize) + const plus = document.createElement('button') + plus.className = 'settings-opt' + plus.textContent = 'A+' + plus.addEventListener('click', () => apply({ ...s, fontSize: Math.min(24, s.fontSize + 1) })) + fontRow.append(minus, size, plus) + + panel.append(themeRow, fontRow) + } + + function label(text: string): HTMLElement { + const el = document.createElement('span') + el.className = 'settings-label' + el.textContent = text + return el + } + + document.body.appendChild(panel) + + const toggle = document.createElement('button') + toggle.className = 'toolbtn' + toggle.textContent = '⚙' + toggle.title = 'Settings (theme, font)' + toggle.setAttribute('aria-label', 'Settings') + toggle.addEventListener('click', () => { + if (panel.style.display === 'none') { + render() + panel.style.display = 'block' + } else { + panel.style.display = 'none' + } + }) + toolbar.appendChild(toggle) +} diff --git a/public/style.css b/public/style.css index f26357b..46b45ff 100644 --- a/public/style.css +++ b/public/style.css @@ -36,12 +36,223 @@ html, body { border-bottom: 1px solid #3a3a3a; display: flex; align-items: stretch; + z-index: 1001; +} + +/* tabs: scrollable; toolbar: fixed cluster on the right */ +#tabs { + flex: 1 1 auto; + display: flex; + align-items: stretch; overflow-x: auto; overflow-y: hidden; - z-index: 1001; scrollbar-width: thin; } +#toolbar { + flex: 0 0 auto; + display: flex; + align-items: center; + border-left: 1px solid #3a3a3a; +} + +.toolbtn { + background: none; + border: none; + color: #aaa; + cursor: pointer; + font-size: 15px; + padding: 0 12px; + height: 100%; +} +.toolbtn:hover { + color: #fff; + background-color: #2a2a2a; +} + +/* Search box overlay (M1) */ +#searchbox { + position: fixed; + top: calc(var(--tabbar-h) + 6px); + right: 8px; + z-index: 1100; + display: flex; + gap: 4px; + padding: 6px; + background-color: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} +.search-input { + width: 180px; + padding: 4px 6px; + font: inherit; + font-size: 13px; + color: #fff; + background: #111; + border: 1px solid #444; + border-radius: 3px; + outline: none; +} +#searchbox button { + background: #333; + border: 1px solid #444; + color: #ddd; + cursor: pointer; + border-radius: 3px; + padding: 0 8px; +} +#searchbox button:hover { + background: #555; +} + +/* QR connect modal (M5) */ +#qrmodal { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); +} +.qr-card { + background: #fff; + color: #111; + border-radius: 12px; + padding: 20px; + text-align: center; + max-width: 90vw; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.qr-title { + font-weight: 600; + margin-bottom: 12px; +} +.qr-url { + margin-top: 10px; + font-size: 13px; + color: #333; + word-break: break-all; +} +.qr-note { + margin-top: 8px; + font-size: 12px; + color: #666; + max-width: 240px; +} +.qr-close { + margin-top: 14px; + padding: 6px 18px; + border: none; + border-radius: 6px; + background: #1a1a1a; + color: #fff; + cursor: pointer; + font: inherit; +} + +/* Settings panel (M3) */ +#settingspanel { + position: fixed; + top: calc(var(--tabbar-h) + 6px); + right: 8px; + z-index: 1100; + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 6px; + padding: 10px; + min-width: 210px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + color: #ddd; +} +.settings-row { + display: flex; + align-items: center; + gap: 6px; + margin: 6px 0; +} +.settings-label { + width: 48px; + color: #aaa; + font-size: 13px; +} +.settings-opt { + background: #333; + border: 1px solid #444; + color: #ddd; + border-radius: 4px; + padding: 4px 9px; + cursor: pointer; + font: inherit; + font-size: 12px; +} +.settings-opt.active { + background: #4a9eff; + color: #fff; + border-color: #4a9eff; +} +.settings-size { + min-width: 22px; + text-align: center; +} + +/* Dashboard (M7) */ +#dashboard { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 56px; + background: rgba(0, 0, 0, 0.6); +} +.dash-card { + background: #222; + border: 1px solid #3a3a3a; + border-radius: 10px; + min-width: 320px; + max-width: 90vw; + max-height: 70vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.dash-title { + padding: 12px 16px; + font-weight: 600; + color: #fff; + border-bottom: 1px solid #3a3a3a; +} +.dash-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + color: #ddd; + border-bottom: 1px solid #2a2a2a; +} +.dash-row:hover { + background: #2a2a2a; +} +.dash-row.active { + background: #1a1a1a; + box-shadow: inset 3px 0 0 #4a9eff; +} +.dash-name { + flex: 1; +} +.dash-claude { + color: #aaa; + font-size: 13px; +} +.dash-claude.pending { + color: #ffcf8f; + font-weight: 600; +} + .tab { display: flex; align-items: center; @@ -91,6 +302,55 @@ html, body { font-weight: 600; } +/* Claude Code activity glyph (H2/H4) */ +.tab-claude { + margin-right: 6px; + font-size: 12px; + flex: none; +} +/* A background tab that needs approval gets an amber outline. */ +.tab.claude-waiting { + background-color: #3a2a10; + box-shadow: inset 0 -2px 0 #d29922; +} + +/* Approve/reject banner (H3) — sits just above the key bar. */ +#approvalbar { + position: fixed; + left: 0; + right: 0; + bottom: var(--keybar-h); + z-index: 1050; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: #3a2a10; + border-top: 1px solid #d29922; + color: #ffcf8f; + font-size: 14px; +} +.approval-label { + flex: 1 1 auto; +} +#approvalbar button { + flex: none; + border: none; + border-radius: 5px; + padding: 7px 16px; + cursor: pointer; + font: inherit; + font-weight: 600; +} +.approval-yes { + background: #2ea043; + color: #fff; +} +.approval-no { + background: #c93c37; + color: #fff; +} + /* Drag-to-reorder feedback. */ .tab.dragging { opacity: 0.4; diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..d9032cd --- /dev/null +++ b/public/sw.js @@ -0,0 +1,28 @@ +/** + * public/sw.js — minimal service worker (M4: PWA installability + offline shell). + * + * Network-first so we never serve a stale build while online; falls back to the + * cache when offline. Never intercepts the WebSocket (/term) or hook endpoints. + */ + +const CACHE = 'webterm-v1' + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())) + +self.addEventListener('fetch', (e) => { + const req = e.request + if (req.method !== 'GET') return + const url = new URL(req.url) + if (url.pathname === '/term' || url.pathname.startsWith('/hook')) return // WS / hooks + + e.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone() + caches.open(CACHE).then((c) => c.put(req, copy)) + return res + }) + .catch(() => caches.match(req)), + ) +}) diff --git a/public/tabs.ts b/public/tabs.ts index 6a4f673..1b4e423 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -17,6 +17,7 @@ */ import { TerminalSession } from './terminal-session.js' +import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' @@ -40,15 +41,51 @@ export class TabApp { private activeIndex = -1 private editingIndex = -1 private dragIndex = -1 + private notifyAsked = false + private settings: Settings = DEFAULT_SETTINGS private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement + private readonly approvalBar: HTMLDivElement constructor(paneHost: HTMLElement, tabBar: HTMLElement) { this.paneHost = paneHost this.tabBar = tabBar + this.approvalBar = document.createElement('div') + this.approvalBar.id = 'approvalbar' + this.approvalBar.style.display = 'none' + document.body.appendChild(this.approvalBar) this.restore() } + /** Show/hide the approve/reject banner for the active tab's held request (H3). */ + private updateApprovalBar(): void { + const session = this.tabs[this.activeIndex]?.session + if (!session || !session.pendingApproval) { + this.approvalBar.style.display = 'none' + return + } + this.approvalBar.replaceChildren() + const label = document.createElement('span') + label.className = 'approval-label' + label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}` + const approve = document.createElement('button') + approve.className = 'approval-yes' + approve.textContent = '✓ Approve' + approve.addEventListener('click', () => { + session.approve() + this.updateApprovalBar() + }) + const reject = document.createElement('button') + reject.className = 'approval-no' + reject.textContent = '✗ Reject' + reject.addEventListener('click', () => { + session.reject() + this.updateApprovalBar() + }) + this.approvalBar.append(label, approve, reject) + this.approvalBar.style.display = 'flex' + } + private displayTitle(entry: TabEntry, idx: number): string { return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` } @@ -114,7 +151,7 @@ export class TabApp { /* ── tab lifecycle ───────────────────────────────────────────── */ - private addEntry(sessionId: string | null, customTitle: string | null): TabEntry { + private addEntry(sessionId: string | null, customTitle: string | null, cwd?: string): TabEntry { const entry: TabEntry = { session: null as unknown as TerminalSession, customTitle, @@ -124,6 +161,7 @@ export class TabApp { } entry.session = new TerminalSession({ sessionId, + ...(cwd !== undefined ? { cwd } : {}), onSessionId: () => this.persist(), // onActivity only fires for hidden (inactive) panes (see TerminalSession). onActivity: () => { @@ -135,15 +173,26 @@ export class TabApp { this.refreshTab(entry) }, onStatus: () => this.refreshTab(entry), + onClaudeStatus: (status) => { + this.refreshTab(entry) + this.updateApprovalBar() + // Notify when a background tab needs approval (H2/H4). + if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) { + this.notify(entry) + } + }, }) this.paneHost.appendChild(entry.session.el) this.tabs.push(entry) + entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) entry.session.connect() return entry } newTab(): void { - this.addEntry(null, null) + // M6: open the new tab in the active tab's current directory, if known. + const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined + this.addEntry(null, null, cwd) this.persist() this.rebuild() this.activate(this.tabs.length - 1) @@ -151,11 +200,13 @@ export class TabApp { activate(i: number): void { if (i < 0 || i >= this.tabs.length) return + this.maybeAskNotify() // first switch is a user gesture — request notif permission this.activeIndex = i const entry = this.tabs[i] if (entry) entry.hasActivity = false // viewing clears the unread dot this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide())) this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild + this.updateApprovalBar() this.persist() } @@ -206,6 +257,64 @@ export class TabApp { this.tabs[this.activeIndex]?.session.send(data) } + /** Apply theme + font settings to every terminal (M3). */ + applySettings(s: Settings): void { + this.settings = s + const theme = THEMES[s.theme] ?? THEMES['dark']! + for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize) + } + + /** A read-only view of all tabs for the dashboard (M7). */ + snapshot(): Array<{ + idx: number + title: string + conn: string + claude: string + active: boolean + pending: boolean + }> { + return this.tabs.map((t, idx) => ({ + idx, + title: this.displayTitle(t, idx), + conn: t.session.status, + claude: t.session.claudeStatus, + active: idx === this.activeIndex, + pending: t.session.pendingApproval, + })) + } + + focusTab(idx: number): void { + this.activate(idx) + } + + /** Scrollback search in the active tab (M1). */ + findInActive(query: string, dir: 'next' | 'prev'): void { + const s = this.tabs[this.activeIndex]?.session + if (!s) return + if (dir === 'next') s.findNext(query) + else s.findPrevious(query) + } + + clearActiveSearch(): void { + this.tabs[this.activeIndex]?.session.clearSearch() + } + + /** Ask for notification permission once, on a user gesture (H2/H4). */ + private maybeAskNotify(): void { + if (this.notifyAsked) return + this.notifyAsked = true + if (typeof Notification !== 'undefined' && Notification.permission === 'default') { + void Notification.requestPermission() + } + } + + /** Browser notification for a background tab needing approval. */ + private notify(entry: TabEntry): void { + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return + const title = this.displayTitle(entry, this.tabs.indexOf(entry)) + new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' }) + } + /* ── rendering ───────────────────────────────────────────────── */ /** In-place update of one tab's classes/label/dot — never destroys the DOM. */ @@ -215,12 +324,19 @@ export class TabApp { const idx = this.tabs.indexOf(entry) el.classList.toggle('active', idx === this.activeIndex) el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex) + const cs = entry.session.claudeStatus + el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex) const title = this.displayTitle(entry, idx) el.title = title const dot = el.querySelector('.tab-dot') if (dot) dot.className = `tab-dot dot-${entry.session.status}` const label = el.querySelector('.tab-label') if (label) label.textContent = title + const claude = el.querySelector('.tab-claude') + if (claude) { + claude.textContent = + cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : '' + } } /** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */ @@ -299,6 +415,10 @@ export class TabApp { }) tabEl.appendChild(label) + const claude = document.createElement('span') + claude.className = 'tab-claude' + tabEl.appendChild(claude) + const close = document.createElement('button') close.className = 'tab-close' close.textContent = '×' diff --git a/public/terminal-session.ts b/public/terminal-session.ts index f56aa23..2ec5731 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -9,9 +9,12 @@ */ import { Terminal } from '@xterm/xterm' +import type { ITheme } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' -import type { ClientMessage, ServerMessage } from '../src/types.js' -import { folderFromTitle } from './title-util.js' +import { SearchAddon } from '@xterm/addon-search' +import { WebLinksAddon } from '@xterm/addon-web-links' +import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js' +import { folderFromTitle, cwdFromOsc7 } from './title-util.js' const RESET = '\x1b[0m' const BOLD = '\x1b[1m' @@ -49,6 +52,10 @@ export interface TerminalSessionOpts { onTitle?: (title: string) => void /** Optional: fired when connection status changes (for the tab status dot). */ onStatus?: (status: SessionStatus) => void + /** Optional: fired when Claude Code activity changes (from a hook, H2). */ + onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void + /** Optional: spawn a NEW session in this directory ("new tab here", M6). */ + cwd?: string } export class TerminalSession { @@ -56,12 +63,19 @@ export class TerminalSession { private readonly term: Terminal private readonly fitAddon: FitAddon + private readonly searchAddon: SearchAddon private readonly resizeObserver: ResizeObserver private readonly onSessionId: (id: string) => void private readonly onActivity: (() => void) | undefined private readonly onTitle: ((title: string) => void) | undefined private readonly onStatus: ((status: SessionStatus) => void) | undefined + private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined + private readonly spawnCwd: string | undefined private statusValue: SessionStatus = 'connecting' + private claudeStatusValue: ClaudeStatus = 'unknown' + private cwdValue: string | null = null + private pendingApprovalValue = false + private pendingToolValue: string | undefined = undefined private ws: WebSocket | null = null private sessionId: string | null @@ -80,6 +94,8 @@ export class TerminalSession { this.onActivity = opts.onActivity this.onTitle = opts.onTitle this.onStatus = opts.onStatus + this.onClaudeStatus = opts.onClaudeStatus + this.spawnCwd = opts.cwd this.el = document.createElement('div') this.el.className = 'term-pane' @@ -92,11 +108,20 @@ export class TerminalSession { }) this.fitAddon = new FitAddon() this.term.loadAddon(this.fitAddon) + this.searchAddon = new SearchAddon() + this.term.loadAddon(this.searchAddon) + this.term.loadAddon(new WebLinksAddon()) // M2: tap a URL Claude prints to open it this.term.open(this.el) this.term.onData((data) => this.send(data)) // Tab title = current folder, derived from the shell's OSC title. this.term.onTitleChange((title) => this.onTitle?.(folderFromTitle(title) ?? title)) + // OSC 7 reports the full cwd (M6) — used for "new tab here". + this.term.parser.registerOscHandler(7, (payload) => { + const c = cwdFromOsc7(payload) + if (c !== null) this.cwdValue = c + return true + }) this.resizeObserver = new ResizeObserver(() => this.scheduleResize()) this.resizeObserver.observe(this.el) @@ -112,6 +137,24 @@ export class TerminalSession { return this.statusValue } + /** Current Claude Code activity (from hooks, H2). */ + get claudeStatus(): ClaudeStatus { + return this.claudeStatusValue + } + + /** Current working directory reported by the shell via OSC 7 (M6), or null. */ + get cwd(): string | null { + return this.cwdValue + } + + /** Whether a tool approval is held server-side and can be resolved (H3). */ + get pendingApproval(): boolean { + return this.pendingApprovalValue + } + get pendingTool(): string | undefined { + return this.pendingToolValue + } + private setStatus(s: SessionStatus): void { this.statusValue = s this.onStatus?.(s) @@ -162,7 +205,13 @@ export class TerminalSession { this.reconnectDelay = 1000 this.setStatus('connected') this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`)) - socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId })) + // Send cwd only on a fresh session (M6 "new tab here"); on reconnect the + // sessionId is already set and the server ignores cwd. + const attachMsg: ClientMessage = + this.sessionId === null && this.spawnCwd !== undefined + ? { type: 'attach', sessionId: null, cwd: this.spawnCwd } + : { type: 'attach', sessionId: this.sessionId } + socket.send(buildMessage(attachMsg)) }) socket.addEventListener('message', (event: MessageEvent) => { @@ -200,6 +249,13 @@ export class TerminalSession { if (this.el.style.display === 'none') this.onActivity?.() break } + case 'status': { + this.claudeStatusValue = msg.status + this.pendingApprovalValue = msg.pending === true + this.pendingToolValue = msg.pending === true ? msg.detail : undefined + this.onClaudeStatus?.(msg.status, msg.detail) + break + } case 'exit': { this.setStatus('exited') const reason = msg.reason ? ` (${msg.reason})` : '' @@ -238,10 +294,45 @@ export class TerminalSession { }, delay) } + private sendMsg(msg: ClientMessage): void { + if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return + this.ws.send(buildMessage(msg)) + } + /** Send raw bytes as input (used by term.onData and the key bar). */ send(data: string): void { - if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return - this.ws.send(buildMessage({ type: 'input', data })) + this.sendMsg({ type: 'input', data }) + } + + /** Resolve a held PermissionRequest (H3). */ + approve(): void { + this.pendingApprovalValue = false + this.sendMsg({ type: 'approve' }) + } + reject(): void { + this.pendingApprovalValue = false + this.sendMsg({ type: 'reject' }) + } + + /** Scrollback search (M1). */ + findNext(query: string): void { + this.searchAddon.findNext(query) + } + findPrevious(query: string): void { + this.searchAddon.findPrevious(query) + } + clearSearch(): void { + this.searchAddon.clearDecorations() + } + + /** Apply a theme + font size (M3) and re-fit if visible. */ + applyTheme(theme: ITheme, fontSize: number): void { + this.term.options.theme = theme + this.term.options.fontSize = fontSize + if (this.el.style.display !== 'none') { + const dims = this.safefit() + if (dims !== null) this.sendResize(dims.cols, dims.rows) + } } /** Make this pane visible, fit it, and focus it. */ diff --git a/public/title-util.ts b/public/title-util.ts index e3f14fe..95ac781 100644 --- a/public/title-util.ts +++ b/public/title-util.ts @@ -23,3 +23,19 @@ export function folderFromTitle(title: string): string | null { if (slash !== -1) s = s.slice(slash + 1) return s || null } + +/** + * Parse the full cwd path from an OSC 7 sequence payload (M6). + * data = "file://host/Users/me/dir" → "/Users/me/dir" + * Returns null if it isn't a file:// URL. + */ +export function cwdFromOsc7(data: string): string | null { + const m = /^file:\/\/[^/]*(\/.*)$/.exec(data.trim()) + if (m === null) return null + const path = m[1] as string + try { + return decodeURIComponent(path) + } catch { + return path + } +} diff --git a/scripts/setup-hooks.mjs b/scripts/setup-hooks.mjs new file mode 100644 index 0000000..8bf2a5e --- /dev/null +++ b/scripts/setup-hooks.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * scripts/setup-hooks.mjs — install/remove web-terminal's Claude Code hooks (H2). + * + * Adds `command` hooks to ~/.claude/settings.json that POST each hook event to + * the web-terminal server so it can show live Claude status per tab. The command + * is an inline curl that uses two env vars the server injects into each spawned + * shell: $WEBTERM_HOOK_URL (the server's /hook URL, with the right port) and + * $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so + * curl no-ops (|| true) — the hooks are harmless in normal terminals. + * + * Usage: + * node scripts/setup-hooks.mjs # install (idempotent) + * node scripts/setup-hooks.mjs --remove # uninstall + * + * Backs up settings.json to settings.json.webterm-bak before writing. + */ + +import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' + +const SETTINGS = path.join(homedir(), '.claude', 'settings.json') +const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries + +// Fire-and-forget status events: POST and discard the response. +const FF_COMMAND = + 'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' + + ' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true' +const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'Stop', 'SessionEnd'] + +// PermissionRequest is HELD: curl waits for the server's response (the decision +// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude. +// Empty/failed (outside web-terminal, or timeout) → Claude shows its own prompt. +const PERM_COMMAND = + 'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' + + ' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-' + +const remove = process.argv.includes('--remove') + +let settings = {} +if (existsSync(SETTINGS)) { + try { + settings = JSON.parse(readFileSync(SETTINGS, 'utf8')) + } catch { + console.error(`${SETTINGS} is not valid JSON — aborting (fix it first).`) + process.exit(1) + } + copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`) +} + +if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {} + +// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove). +for (const ev of Object.keys(settings.hooks)) { + const groups = settings.hooks[ev] + if (!Array.isArray(groups)) continue + settings.hooks[ev] = groups.filter( + (g) => + !( + g && + Array.isArray(g.hooks) && + g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(MARKER)) + ), + ) + if (settings.hooks[ev].length === 0) delete settings.hooks[ev] +} + +if (!remove) { + for (const ev of FF_EVENTS) { + if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = [] + settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] }) + } + if (!Array.isArray(settings.hooks['PermissionRequest'])) settings.hooks['PermissionRequest'] = [] + settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: PERM_COMMAND }] }) +} + +mkdirSync(path.dirname(SETTINGS), { recursive: true }) +writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`) + +console.log( + remove + ? `Removed web-terminal hooks from ${SETTINGS}` + : `Installed web-terminal hooks into ${SETTINGS} (events: ${[...FF_EVENTS, 'PermissionRequest'].join(', ')})`, +) +if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`) +console.log('Restart any running `claude` sessions for the hooks to take effect.') diff --git a/src/config.ts b/src/config.ts index 3708fef..1700a5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,15 @@ import os from 'node:os' import type { Config, EnvLike } from './types.js' +import { tmuxAvailable } from './session/tmux.js' + +/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */ +function resolveUseTmux(raw: string | undefined): boolean { + const v = raw?.trim().toLowerCase() + if (v === '1' || v === 'true' || v === 'on') return true + if (v === '0' || v === 'false' || v === 'off') return false + return tmuxAvailable() // unset / 'auto' +} // ── constants ───────────────────────────────────────────────────────────────── @@ -141,6 +150,8 @@ export function loadConfig(env: EnvLike): Config { const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH + const useTmux = resolveUseTmux(env['USE_TMUX']) + const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS']) return Object.freeze({ @@ -152,6 +163,7 @@ export function loadConfig(env: EnvLike): Config { scrollbackBytes, maxPayloadBytes, wsPath, + useTmux, allowedOrigins, } satisfies Config) } diff --git a/src/http/hook.ts b/src/http/hook.ts new file mode 100644 index 0000000..4f9f9c2 --- /dev/null +++ b/src/http/hook.ts @@ -0,0 +1,57 @@ +/** + * src/http/hook.ts (H2) — map a Claude Code hook POST to a session status update. + * + * Pure + never throws. The web-terminal sessionId arrives in the + * `X-Webterm-Session` header (injected as $WEBTERM_SESSION on spawn); the hook + * payload arrives as JSON in the body. We translate the event into a coarse + * ClaudeStatus the UI can show. The server is still a byte-shuttle for the + * terminal stream — this is an out-of-band side-channel. + */ + +import type { ClaudeStatus } from '../types.js'; + +export interface HookEvent { + sessionId: string; + status: ClaudeStatus; + detail?: string; +} + +/** + * @param sessionId value of the `X-Webterm-Session` header + * @param body parsed JSON body of the hook POST (untrusted) + * @returns a status update, or null if the payload is unusable + */ +export function parseHookEvent(sessionId: string | undefined, body: unknown): HookEvent | null { + if (typeof sessionId !== 'string' || sessionId.length === 0) return null; + if (body === null || typeof body !== 'object') return null; + + const b = body as Record; + const event = typeof b['hook_event_name'] === 'string' ? b['hook_event_name'] : ''; + const notif = typeof b['notification_type'] === 'string' ? b['notification_type'] : ''; + const tool = typeof b['tool_name'] === 'string' ? b['tool_name'] : undefined; + + let status: ClaudeStatus; + switch (event) { + case 'PreToolUse': + case 'PostToolUse': + case 'UserPromptSubmit': + status = 'working'; + break; + case 'PermissionRequest': + status = 'waiting'; + break; + case 'Stop': + case 'SessionEnd': + status = 'idle'; + break; + case 'Notification': + status = notif === 'permission_prompt' ? 'waiting' : 'idle'; + break; + default: + return null; + } + + return status === 'waiting' && tool !== undefined + ? { sessionId, status, detail: tool } + : { sessionId, status }; +} diff --git a/src/protocol.ts b/src/protocol.ts index 8361fb2..917369c 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -24,7 +24,7 @@ export const SESSION_ID_RE = // ─── Type whitelist ─────────────────────────────────────────────────────────── -const ALLOWED_TYPES = new Set(['attach', 'input', 'resize']) +const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'approve', 'reject']) // ─── parseClientMessage ─────────────────────────────────────────────────────── @@ -73,6 +73,14 @@ export function parseClientMessage(raw: string): ParseResult { return validateInput(obj) } + // approve/reject (H3) carry no payload. + if (type === 'approve') { + return { ok: true, message: { type: 'approve' } } + } + if (type === 'reject') { + return { ok: true, message: { type: 'reject' } } + } + // type === 'attach' return validateAttach(obj) } @@ -125,8 +133,21 @@ function validateAttach(obj: Record): ParseResult { return { ok: false, error: 'attach.sessionId field is required' } } + // Optional cwd (M6): must be an absolute path string if present. + const rawCwd = obj['cwd'] + let cwd: string | undefined + if (rawCwd !== undefined) { + if (typeof rawCwd !== 'string' || !rawCwd.startsWith('/')) { + return { ok: false, error: 'attach.cwd must be an absolute path string' } + } + cwd = rawCwd + } + if (sessionId === null) { - return { ok: true, message: { type: 'attach', sessionId: null } } + return { + ok: true, + message: cwd !== undefined ? { type: 'attach', sessionId: null, cwd } : { type: 'attach', sessionId: null }, + } } if (typeof sessionId !== 'string') { @@ -143,7 +164,10 @@ function validateAttach(obj: Record): ParseResult { } } - return { ok: true, message: { type: 'attach', sessionId } } + return { + ok: true, + message: cwd !== undefined ? { type: 'attach', sessionId, cwd } : { type: 'attach', sessionId }, + } } // ─── serialize ──────────────────────────────────────────────────────────────── diff --git a/src/server.ts b/src/server.ts index b2a00a6..9d32ea4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,6 +28,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import express from 'express' +import type { Response } from 'express' import { WebSocketServer } from 'ws' import type { WebSocket as WsWebSocket } from 'ws' import type { IncomingMessage } from 'node:http' @@ -35,6 +36,7 @@ import type { IncomingMessage } from 'node:http' import { loadConfig } from './config.js' import { parseClientMessage, serialize } from './protocol.js' import { isOriginAllowed } from './http/origin.js' +import { parseHookEvent } from './http/hook.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, resize } from './session/session.js' import type { Config } from './types.js' @@ -45,6 +47,20 @@ import { WS_OPEN } from './types.js' const DEFAULT_COLS = 80 const DEFAULT_ROWS = 24 +// H3: how long the server holds a PermissionRequest before falling back to +// Claude's own interactive prompt (so it never hangs if nobody responds). +const PERM_TIMEOUT_MS = 5 * 60_000 + +/** The decision JSON a PermissionRequest command hook writes to stdout. */ +function permDecision(behavior: 'allow' | 'deny'): unknown { + return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } } +} + +/** True for loopback peers (hooks always run on the host). */ +function isLoopback(ip: string): boolean { + return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' +} + // ── helpers ─────────────────────────────────────────────────────────────────── /** Derive __dirname equivalent in ESM. */ @@ -69,6 +85,18 @@ function safeSend(ws: WsWebSocket, data: string): void { export function startServer(cfg: Config): { close(): Promise } { const manager = createSessionManager(cfg) + // H3: held PermissionRequest responses, keyed by sessionId. The express `res` + // is parked here until the client approves/rejects (or it times out). + const pendingApprovals = new Map }>() + + function resolvePending(sessionId: string, decision: unknown): void { + const p = pendingApprovals.get(sessionId) + if (p === undefined) return + clearTimeout(p.timer) + pendingApprovals.delete(sessionId) + p.res.json(decision) + } + // ── Express static hosting ──────────────────────────────────────────────── const app = express() @@ -77,6 +105,53 @@ export function startServer(cfg: Config): { close(): Promise } { const publicDir = path.join(__dirname, '..', 'public') app.use(express.static(publicDir)) + // ── Claude Code hook side-channel (H2) ──────────────────────────────────── + // Hooks running inside a spawned shell POST status here (loopback only — the + // shell runs on the host). sessionId arrives in the X-Webterm-Session header. + app.post('/hook', express.json({ limit: '64kb' }), (req, res) => { + if (!isLoopback(req.socket.remoteAddress ?? '')) { + res.status(403).end() + return + } + const ev = parseHookEvent(req.header('x-webterm-session'), req.body) + if (ev === null) { + res.status(400).end() + return + } + manager.handleHookEvent(ev.sessionId, ev.status, ev.detail) + res.status(204).end() + }) + + // H3: PermissionRequest hook — held until the client approves/rejects. The + // hook's curl writes our response (the decision JSON) to stdout for Claude. + app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => { + if (!isLoopback(req.socket.remoteAddress ?? '')) { + res.status(403).end() + return + } + const sessionId = req.header('x-webterm-session') + const body = (req.body ?? {}) as Record + const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined + const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined + + // Nobody watching this tab (no session / not attached) → let Claude prompt itself. + if (sessionId === undefined || session === undefined || session.attachedWs === null) { + res.json({}) + return + } + + resolvePending(sessionId, {}) // clear any stale hold for this session + const timer = setTimeout(() => { + pendingApprovals.delete(sessionId) + res.json({}) // timeout → fall back to Claude's interactive prompt + manager.handleHookEvent(sessionId, 'idle') + }, PERM_TIMEOUT_MS) + pendingApprovals.set(sessionId, { res, timer }) + + // Show the approve/reject affordance on the client. + manager.handleHookEvent(sessionId, 'waiting', tool, true) + }) + // ── HTTP server ─────────────────────────────────────────────────────────── const httpServer = createServer(app) @@ -154,7 +229,7 @@ export function startServer(cfg: Config): { close(): Promise } { let session try { - session = manager.handleAttach(ws, msg.sessionId, dims, Date.now()) + session = manager.handleAttach(ws, msg.sessionId, dims, Date.now(), msg.cwd) } catch (err: unknown) { // M4: spawn failure — send exit(-1) and close only this connection. const reason = @@ -182,6 +257,13 @@ export function startServer(cfg: Config): { close(): Promise } { writeInput(session, msg.data) } else if (msg.type === 'resize') { resize(session, msg.cols, msg.rows) + } else if (msg.type === 'approve') { + // H3: resolve the held PermissionRequest with allow. + resolvePending(boundSessionId, permDecision('allow')) + manager.handleHookEvent(boundSessionId, 'working', undefined, false) + } else if (msg.type === 'reject') { + resolvePending(boundSessionId, permDecision('deny')) + manager.handleHookEvent(boundSessionId, 'working', undefined, false) } else if (msg.type === 'attach') { // Re-attach after first bind is not expected; log and ignore. console.error('[server] unexpected re-attach on established connection') @@ -194,6 +276,9 @@ export function startServer(cfg: Config): { close(): Promise } { ws.on('close', () => { if (boundSessionId === null) return + // H3: release any held approval so the hook isn't stuck for the full timeout. + resolvePending(boundSessionId, {}) + const session = manager.get(boundSessionId) if (session === undefined) return @@ -219,6 +304,7 @@ export function startServer(cfg: Config): { close(): Promise } { console.error('[server] shutdown signal received — cleaning up') doShutdown() httpServer.close(() => process.exit(0)) + httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains } process.on('SIGINT', onSignal) @@ -246,6 +332,9 @@ export function startServer(cfg: Config): { close(): Promise } { return new Promise((resolve) => { doShutdown() httpServer.close(() => resolve()) + // Drop idle keep-alive sockets (e.g. an undici hook connection) so the + // close callback can fire instead of waiting for the keep-alive timeout. + httpServer.closeIdleConnections() }) }, } diff --git a/src/session/manager.ts b/src/session/manager.ts index 480b8b2..c7596a2 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -22,10 +22,18 @@ * Coding style: immutable-update preference, no console.log, errors explicit. */ -import type { Config, Dims, Session, SessionManager, WebSocketLike } from '../types.js'; +import type { + ClaudeStatus, + Config, + Dims, + Session, + SessionManager, + WebSocketLike, +} from '../types.js'; import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createSession, attachWs, kill } from './session.js'; +import { hasSession, tmuxName } from './tmux.js'; /** * Send a serialised ServerMessage to `ws` only when it is OPEN (M5). @@ -74,11 +82,13 @@ export function createSessionManager(cfg: Config): SessionManager { sessionId: string | null, dims: Dims, now: number, + cwd?: string, ): Session { // ── Case 1: null → always create a new session ────────────────────────── if (sessionId === null) { // M4: createSession may throw (spawn failure). Do NOT catch here. - const session = createSession(cfg, dims, now, onSessionExit); + // M6: cwd (if given) is the spawn directory for "new tab here". + const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd); const kicked = attachWs(session, ws); if (kicked !== null) kicked.close(); sessions = new Map(sessions).set(session.meta.id, session); @@ -106,6 +116,17 @@ export function createSessionManager(cfg: Config): SessionManager { return existing; } + // ── Case 3.5 (H1): not in our table, but a tmux session survived a restart ─ + // Re-attach with the SAME id so `tmux new-session -A -s web_` reconnects + // to the still-running shell instead of spawning a fresh one. + if (cfg.useTmux && hasSession(tmuxName(sessionId))) { + const revived = createSession(cfg, dims, now, onSessionExit, sessionId); + const kickedRevived = attachWs(revived, ws); + if (kickedRevived !== null) kickedRevived.close(); + sessions = new Map(sessions).set(revived.meta.id, revived); + return revived; + } + // ── Case 4: session not found → create a new one ───────────────────────── // M4: createSession may throw. Do NOT catch here. const session = createSession(cfg, dims, now, onSessionExit); @@ -119,6 +140,28 @@ export function createSessionManager(cfg: Config): SessionManager { return sessions.get(id); } + /** + * H2: a Claude Code hook reported activity for `sessionId`. Record it and push + * a `status` frame to the attached ws (no-op if the session is gone/detached). + */ + function handleHookEvent( + sessionId: string, + status: ClaudeStatus, + detail?: string, + pending?: boolean, + ): void { + const session = sessions.get(sessionId); + if (session === undefined) return; + session.claudeStatus = status; + const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = { + type: 'status', + status, + }; + if (detail !== undefined) msg.detail = detail; + if (pending) msg.pending = true; + sendIfOpen(session.attachedWs, msg); + } + /** * Reclaim detached sessions whose liveness window has expired (M3). * @@ -151,13 +194,21 @@ export function createSessionManager(cfg: Config): SessionManager { return count; } - /** Kill every live session and clear the table (called on SIGINT/SIGTERM). */ + /** + * Called on SIGINT/SIGTERM/close. For non-tmux sessions, kill the PTY. For + * tmux sessions (H1), kill only the client pty — the tmux server keeps the + * shell alive so it survives the restart and can be re-attached. + */ function shutdown(): void { for (const session of sessions.values()) { - kill(session); + if (session.tmuxName !== null) { + session.pty.kill(); // detach client; tmux keeps the shell + } else { + kill(session); + } } sessions = new Map(); } - return { handleAttach, get, reapIdle, shutdown }; + return { handleAttach, get, handleHookEvent, reapIdle, shutdown }; } diff --git a/src/session/session.ts b/src/session/session.ts index c04479f..6806219 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -37,6 +37,7 @@ import type { import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createRingBuffer } from './ring-buffer.js'; +import { tmuxName, killSession } from './tmux.js'; /** Send a server message to `ws` only if it is OPEN (M5). Never throws on a * closed socket; forwarding to a dead ws is simply a no-op. */ @@ -57,18 +58,36 @@ export function createSession( dims: Dims, now: number, onExit: (session: Session) => void, + // H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after + // a server restart). Default = a fresh id for a brand-new session. + id: string = randomUUID(), + // M6: spawn directory for a new session ("new tab here"); defaults to homeDir. + cwd?: string, ): Session { - // M4: let a spawn failure (e.g. missing shell) propagate synchronously. - const pty: IPty = spawn(cfg.shellPath, [], { + // The id is injected into the shell env so Claude Code hooks know which tab an + // event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2). + const tName = cfg.useTmux ? tmuxName(id) : null; + + // H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A` + // attaches to web_ if it exists (restart survival) or creates it. + const file = tName !== null ? 'tmux' : cfg.shellPath; + const args = tName !== null ? ['new-session', '-A', '-s', tName, cfg.shellPath] : []; + + // M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously. + const pty: IPty = spawn(file, args, { name: 'xterm-256color', cols: dims.cols, rows: dims.rows, - cwd: cfg.homeDir, - env: process.env, + cwd: cwd ?? cfg.homeDir, + env: { + ...process.env, + WEBTERM_SESSION: id, + WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`, + }, }); const meta: SessionMeta = { - id: randomUUID(), + id, createdAt: now, shellPath: cfg.shellPath, }; @@ -81,6 +100,8 @@ export function createSession( lastOutputAt: now, exitedAt: null, exitCode: null, + claudeStatus: 'unknown', + tmuxName: tName, pty, }; @@ -139,7 +160,12 @@ export function resize(session: Session, cols: number, rows: number): void { session.pty.resize(cols, rows); } -/** Kill the underlying PTY (process exit / idle reclaim). */ +/** + * Kill the session (idle reclaim). Under tmux this ends the actual shell via + * `tmux kill-session` (H1); killing only the client pty would leave the tmux + * session running. Always also kills the client pty. + */ export function kill(session: Session): void { + if (session.tmuxName !== null) killSession(session.tmuxName); session.pty.kill(); } diff --git a/src/session/tmux.ts b/src/session/tmux.ts new file mode 100644 index 0000000..451120f --- /dev/null +++ b/src/session/tmux.ts @@ -0,0 +1,46 @@ +/** + * src/session/tmux.ts (H1) — thin synchronous wrappers around the tmux CLI. + * + * Used only when cfg.useTmux is on. Running the shell inside a tmux session + * lets it survive a server/host restart: the node-pty process is just a tmux + * *client*; killing it detaches, and the tmux server keeps the shell alive. + * + * Every call is best-effort and never throws (tmux missing / session gone are + * treated as "false"/no-op). + */ + +import { execFileSync } from 'node:child_process' + +/** Is the tmux binary available on PATH? */ +export function tmuxAvailable(): boolean { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** tmux session name for a web-terminal session id (UUIDs are tmux-safe). */ +export function tmuxName(sessionId: string): string { + return `web_${sessionId}` +} + +/** Does a tmux session with this name already exist (e.g. after a restart)? */ +export function hasSession(name: string): boolean { + try { + execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** Kill a tmux session (ends the shell). No-op if it's already gone. */ +export function killSession(name: string): void { + try { + execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' }) + } catch { + // already gone — fine + } +} diff --git a/src/types.ts b/src/types.ts index 2a48370..e69ad34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,7 @@ export interface Config { readonly scrollbackBytes: number; // ring buffer capacity, default 2MB readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5) readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8) + readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1) } @@ -38,18 +39,27 @@ export type EnvLike = Readonly>; /* ─────────────────────── protocol (§3.2) ─────────────────────── */ -/** client → server */ +/** client → server. approve/reject (H3) resolve a held PermissionRequest. + * attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */ export type ClientMessage = - | { type: 'attach'; sessionId: string | null } + | { type: 'attach'; sessionId: string | null; cwd?: string } | { type: 'input'; data: string } - | { type: 'resize'; cols: number; rows: number }; + | { type: 'resize'; cols: number; rows: number } + | { type: 'approve' } + | { type: 'reject' }; + +/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */ +export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown'; /** server → client. exit.code = shell code; -1 when spawn never succeeded (M4). - * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. */ + * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. + * status (H2/H3) = Claude Code activity; `pending` true when a tool approval + * is held server-side and the client can approve/reject it. */ export type ServerMessage = | { type: 'attached'; sessionId: string } | { type: 'output'; data: string } - | { type: 'exit'; code: number; reason?: string }; + | { type: 'exit'; code: number; reason?: string } + | { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean }; /** parseClientMessage result — never throws; errors flow here (§5.3). */ export type ParseResult = @@ -139,6 +149,10 @@ export interface Session { * and attach replays the buffer then re-sends `exit` (L1). */ exitedAt: number | null; exitCode: number | null; + /** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */ + claudeStatus: ClaudeStatus; + /** tmux session name (H1) when running under tmux, else null. */ + readonly tmuxName: string | null; readonly pty: IPty; } @@ -154,8 +168,21 @@ export interface Session { /* ──────────────────────── manager (§3.5) ─────────────────────── */ export interface SessionManager { - handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; + handleAttach( + ws: WebSocketLike, + sessionId: string | null, + dims: Dims, + now: number, + cwd?: string, + ): Session; get(id: string): Session | undefined; + /** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */ + handleHookEvent( + sessionId: string, + status: ClaudeStatus, + detail?: string, + pending?: boolean, + ): void; /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */ reapIdle(now: number): number; shutdown(): void; diff --git a/test/hook.test.ts b/test/hook.test.ts new file mode 100644 index 0000000..daa8ac6 --- /dev/null +++ b/test/hook.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { parseHookEvent } from '../src/http/hook.js' + +const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' + +describe('parseHookEvent', () => { + it('maps tool events to "working"', () => { + for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) { + expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({ + sessionId: SID, + status: 'working', + }) + } + }) + + it('maps PermissionRequest to "waiting" with the tool name as detail', () => { + expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual( + { sessionId: SID, status: 'waiting', detail: 'Bash' }, + ) + }) + + it('maps Notification permission_prompt to "waiting", idle_prompt to "idle"', () => { + expect( + parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }), + ).toEqual({ sessionId: SID, status: 'waiting' }) + expect( + parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }), + ).toEqual({ sessionId: SID, status: 'idle' }) + }) + + it('maps Stop / SessionEnd to "idle"', () => { + expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual({ sessionId: SID, status: 'idle' }) + expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({ + sessionId: SID, + status: 'idle', + }) + }) + + it('returns null for missing sessionId, bad body, or unknown event', () => { + expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull() + expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull() + expect(parseHookEvent(SID, null)).toBeNull() + expect(parseHookEvent(SID, 'nope')).toBeNull() + expect(parseHookEvent(SID, { hook_event_name: 'SomethingElse' })).toBeNull() + expect(parseHookEvent(SID, {})).toBeNull() + }) +}) diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index adfec84..f8eb4bc 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -25,6 +25,7 @@ */ import net from 'node:net' +import { execFileSync } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import WebSocket from 'ws' @@ -49,6 +50,19 @@ const PTY_AVAILABLE = (() => { })() const itPty = PTY_AVAILABLE ? it : it.skip +/** H1 tmux tests need real PTY + the tmux binary. */ +const TMUX_OK = + PTY_AVAILABLE && + (() => { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } + })() +const itTmux = TMUX_OK ? it : it.skip + // ── Helpers ────────────────────────────────────────────────────────────────── /** Pick a free port on 127.0.0.1 by briefly binding to port 0. */ @@ -88,6 +102,7 @@ function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 * // Keep idle TTL tiny so stray sessions don't linger. IDLE_TTL: '86400', MAX_PAYLOAD_BYTES: String(maxPayloadBytes), + USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions }) } @@ -545,4 +560,174 @@ describe('startServer — integration', () => { await waitForClose(ws2, 3_000).catch(() => undefined) }, ) + + // ── ⑦ Hook side-channel → status push (H2, needs real PTY — sandbox-off) ─── + itPty( + '⑦ [needs real PTY (sandbox-off)] POST /hook pushes a status frame to the attached ws (H2)', + async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + await waitForOpen(ws, 3_000) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = (await waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', + 5_000, + )) as Record + const sessionId = attached['sessionId'] as string + + // Arm the status listener BEFORE firing the hook — the server pushes the + // status frame synchronously (before the POST returns 204), so attaching + // the listener after the fetch would race past it. + const statusPromise = waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'status', + 5_000, + ) + + // Simulate a Claude Code PermissionRequest hook firing for this session. + const res = await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' }), + }) + expect(res.status).toBe(204) + + const status = (await statusPromise) as Record + expect(status['status']).toBe('waiting') + expect(status['detail']).toBe('Bash') + + ws.close() + await waitForClose(ws, 3_000).catch(() => undefined) + }, + ) + + // ── ⑧ Held PermissionRequest resolves on approve (H3, real PTY — sandbox-off) ─ + itPty( + '⑧ [needs real PTY (sandbox-off)] POST /hook/permission is held until approve → allow decision', + async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + await waitForOpen(ws, 3_000) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = (await waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', + 5_000, + )) as Record + const sessionId = attached['sessionId'] as string + + // Arm the pending-status listener before firing (push is synchronous). + const pendingPromise = waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['pending'] === true, + 5_000, + ) + + // Fire the PermissionRequest hook — this POST is HELD by the server until + // we approve. Do NOT await it yet. + const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ tool_name: 'Bash' }), + }) + + const pending = (await pendingPromise) as Record + expect(pending['status']).toBe('waiting') + expect(pending['pending']).toBe(true) + + // Approve over the ws → the held POST should resolve with an allow decision. + ws.send(JSON.stringify({ type: 'approve' })) + const decision = (await (await permPromise).json()) as { + hookSpecificOutput?: { decision?: { behavior?: string } } + } + expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') + + ws.close() + await waitForClose(ws, 3_000).catch(() => undefined) + }, + ) + + // ── H1: tmux keepalive — a shell survives a full server restart ──────────── + itTmux( + 'H1 [needs tmux + real PTY] shell state survives a server restart', + async () => { + const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + const isAttached = (m: unknown): boolean => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached' + + const tport = await getFreePort() + const tcfg = loadConfig({ + PORT: String(tport), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${tport}`, + IDLE_TTL: '86400', + USE_TMUX: '1', + }) + + let sid = '' + let srv = startServer(tcfg) + await delay(150) + try { + // Attach and set a shell variable inside the tmux shell. + const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { + headers: { Origin: `http://127.0.0.1:${tport}` }, + }) + await waitForOpen(ws1, 3_000) + ws1.send(JSON.stringify({ type: 'attach', sessionId: null })) + const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record + sid = att['sessionId'] as string + await delay(500) + ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' })) + await delay(500) + ws1.close() + await waitForClose(ws1, 3_000).catch(() => undefined) + + // Restart the server — shutdown must keep the tmux session alive (H1). + await srv.close() + srv = startServer(tcfg) + await delay(150) + + // Reconnect with the SAME sessionId → re-attach to the surviving shell. + const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { + headers: { Origin: `http://127.0.0.1:${tport}` }, + }) + await waitForOpen(ws2, 3_000) + const out: string[] = [] + ws2.on('message', (raw) => { + try { + const m = JSON.parse(raw.toString('utf8')) as Record + if (m['type'] === 'output' && typeof m['data'] === 'string') out.push(m['data']) + } catch { + /* ignore */ + } + }) + ws2.send(JSON.stringify({ type: 'attach', sessionId: sid })) + await delay(400) + ws2.send(JSON.stringify({ type: 'input', data: 'echo MARK-$WEBTERM_MARK\r' })) + await delay(900) + // The variable set before the restart is still set → same shell survived. + expect(out.join('')).toContain('MARK-tmuxlives') + ws2.close() + await waitForClose(ws2, 3_000).catch(() => undefined) + } finally { + await srv.close() + if (sid !== '') { + try { + execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' }) + } catch { + /* already gone */ + } + } + } + }, + 20_000, + ) }) diff --git a/test/keybar.test.ts b/test/keybar.test.ts index d724c63..4869c8f 100644 --- a/test/keybar.test.ts +++ b/test/keybar.test.ts @@ -78,10 +78,18 @@ describe('keybar', () => { expect(KEY_MAP.slash).toHaveLength(1) }) - it('all 10 keys are defined', () => { + it('Claude Code control keys map to the right bytes', () => { + expect(KEY_MAP.escEsc).toBe('\x1b\x1b') // rewind / clear draft + expect(KEY_MAP.ctrlO).toBe('\x0f') // expand transcript + expect(KEY_MAP.ctrlT).toBe('\x14') // task list + expect(KEY_MAP.ctrlB).toBe('\x02') // background + }) + + it('all 14 keys are defined', () => { const keys = Object.keys(KEY_MAP) expect(keys).toEqual([ 'esc', + 'escEsc', 'shiftTab', 'arrowUp', 'arrowDown', @@ -89,6 +97,9 @@ describe('keybar', () => { 'arrowRight', 'enter', 'ctrlC', + 'ctrlO', + 'ctrlT', + 'ctrlB', 'tab', 'slash', ]) @@ -96,8 +107,8 @@ describe('keybar', () => { it('all mapped bytes are unique strings', () => { const bytes = Object.values(KEY_MAP) - expect(bytes).toHaveLength(10) - expect(new Set(bytes).size).toBe(10) // all unique + expect(bytes).toHaveLength(14) + expect(new Set(bytes).size).toBe(14) // all unique }) it('ANSI arrow sequences use correct format', () => { diff --git a/test/manager.test.ts b/test/manager.test.ts index f0c1383..d5f5a89 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -46,6 +46,7 @@ const CFG: Config = { scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', + useTmux: false, allowedOrigins: [], }; diff --git a/test/protocol.test.ts b/test/protocol.test.ts index 3ff3d0d..d9c5392 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -107,6 +107,15 @@ describe('parseClientMessage — valid messages', () => { expect(result.message).toEqual({ type: 'resize', cols: 120, rows: 40 }) } }) + + it('parses approve / reject (H3, no payload)', () => { + const a = parseClientMessage(JSON.stringify({ type: 'approve' })) + expect(a.ok).toBe(true) + if (a.ok) expect(a.message).toEqual({ type: 'approve' }) + const r = parseClientMessage(JSON.stringify({ type: 'reject' })) + expect(r.ok).toBe(true) + if (r.ok) expect(r.message).toEqual({ type: 'reject' }) + }) }) // ─── parseClientMessage — invalid / error paths ─────────────────────────────── diff --git a/test/session.test.ts b/test/session.test.ts index e93ed46..70f990b 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -48,6 +48,7 @@ const CFG: Config = { scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', + useTmux: false, allowedOrigins: [], }; diff --git a/test/title-util.test.ts b/test/title-util.test.ts index 5794fc3..87f7e76 100644 --- a/test/title-util.test.ts +++ b/test/title-util.test.ts @@ -1,5 +1,21 @@ import { describe, it, expect } from 'vitest' -import { folderFromTitle } from '../public/title-util.js' +import { folderFromTitle, cwdFromOsc7 } from '../public/title-util.js' + +describe('cwdFromOsc7', () => { + it('extracts the path from a file:// URL with a host', () => { + expect(cwdFromOsc7('file://mac.local/Users/me/web-terminal')).toBe('/Users/me/web-terminal') + }) + it('extracts the path with an empty host (file:///)', () => { + expect(cwdFromOsc7('file:///Users/me/dir')).toBe('/Users/me/dir') + }) + it('decodes percent-encoded spaces', () => { + expect(cwdFromOsc7('file://h/Users/me/my%20dir')).toBe('/Users/me/my dir') + }) + it('returns null for non-file URLs', () => { + expect(cwdFromOsc7('http://x/y')).toBeNull() + expect(cwdFromOsc7('garbage')).toBeNull() + }) +}) describe('folderFromTitle', () => { it('extracts the folder from user@host:~/path', () => {