Merge PR #146 from aakhter/cod-163-command-palette: searchable case picker + Command-K session palette

Includes review fixes: Session Manager aligned to the merged /api/sessions/unified contract with error states, Ctrl+K no longer leaks 0x0B into the PTY, shortcut registry finished (dispatch/persistence/rendering), shortcutOverrides preserved across settings saves, help modal kept reachable.

# Conflicts:
#	README.md
#	src/web/public/index.html
#	src/web/public/session-ui.js
This commit is contained in:
Codeman maintainer
2026-07-12 20:03:53 +02:00
14 changed files with 2711 additions and 194 deletions
+131 -119
View File
@@ -45,6 +45,7 @@ codeman web
<summary><strong>Run as a background service</strong></summary>
**Linux (systemd):**
```bash
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/codeman-web.service << EOF
@@ -67,6 +68,7 @@ loginctl enable-linger $USER
```
**macOS (launchd):**
```bash
mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
@@ -94,6 +96,7 @@ cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
```
</details>
<details>
@@ -104,6 +107,7 @@ wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/i
```
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
</details>
---
@@ -127,12 +131,12 @@ Open the printed URL. The page is a single dashboard; everything below happens t
Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in its own tmux-backed terminal. You choose:
| Field | What it does |
|-------|--------------|
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Gemini`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Claude Model). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
| Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Gemini`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Claude Model). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
Hit start — Codeman spawns the CLI via a real PTY and streams it to your browser over SSE.
@@ -151,13 +155,13 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 5. Make it autonomous
| Mode | Use it for | Where |
|------|-----------|-------|
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab |
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button |
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
| Mode | Use it for | Where |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab |
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button |
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
### 6. Reach it from anywhere
@@ -171,7 +175,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
- **Self-update** — git-clone installs update in place from **Settings → Updates**.
- **Deploy your own changes** — see [Development](#development).
> ⚠️ **Safety:** if you're working *inside* a Codeman-managed session (`echo $CODEMAN_MUX` → `1`), never run `tmux kill-session` / `pkill claude` directly — use the web UI or `./scripts/tmux-manager.sh`.
> ⚠️ **Safety:** if you're working _inside_ a Codeman-managed session (`echo $CODEMAN_MUX` → `1`), never run `tmux kill-session` / `pkill claude` directly — use the web UI or `./scripts/tmux-manager.sh`.
---
@@ -281,7 +285,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
```
- **Multi-layer idle detection** — completion messages, AI-powered idle check, output silence, token stability
- **Auto-resume on usage limit** *(opt-in, off by default)* — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
- **Auto-resume on usage limit** _(opt-in, off by default)_ — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
- **Circuit breaker** — prevents respawn thrashing when Claude is stuck (CLOSED -> HALF_OPEN -> OPEN states, tracks consecutive no-progress and repeated errors)
- **Health scoring** — 0-100 health score with component scores for cycle success, circuit breaker state, iteration progress, and stuck recovery
- **Built-in presets** — `solo-work` (3s idle, 60min), `subagent-workflow` (45s, 240min), `team-lead` (90s, 480min), `ralph-todo` (8s, 480min), `overnight-autonomous` (10s, 480min)
@@ -327,10 +331,10 @@ The title is templated into the served HTML on first byte, so it's correct from
### Smart Token Management
| Threshold | Action | Result |
|-----------|--------|--------|
| Threshold | Action | Result |
| --------------- | --------------- | ---------------------------------- |
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
### Notifications
@@ -365,8 +369,8 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
- **Image input** — paste or drag-and-drop images straight into a session
- **Gesture control** *(opt-in)* — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
- **Multi-monitor span** *(macOS)* — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
@@ -439,14 +443,14 @@ Every **60 seconds**, the server automatically rotates to a fresh token. The pre
The design is informed by ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025), which found 47 of the top-100 websites vulnerable to QR auth attacks due to 6 critical design flaws across 42 CVEs. Codeman addresses all six:
| USENIX Flaw | Mitigation |
|-------------|------------|
| **Flaw-1**: Missing single-use enforcement | Token atomically consumed on first scan — replays always fail |
| **Flaw-2**: Long-lived tokens | 60s TTL with 90s grace, auto-rotation via timer |
| **Flaw-3**: Predictable token generation | `crypto.randomBytes(32)` — 256-bit entropy. Short codes use rejection sampling to eliminate modulo bias |
| **Flaw-4**: Client-side token generation | Server-side only — tokens never leave the server until embedded in the QR |
| **Flaw-5**: Missing status notification | Desktop toast: *"Device [IP] authenticated via QR (Safari). Not you? [Revoke]"* — real-time QRLjacking detection |
| **Flaw-6**: Inadequate session binding | IP + User-Agent stored for audit. Manual session revocation via API. HttpOnly + Secure + SameSite=lax cookies |
| USENIX Flaw | Mitigation |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| **Flaw-1**: Missing single-use enforcement | Token atomically consumed on first scan — replays always fail |
| **Flaw-2**: Long-lived tokens | 60s TTL with 90s grace, auto-rotation via timer |
| **Flaw-3**: Predictable token generation | `crypto.randomBytes(32)` — 256-bit entropy. Short codes use rejection sampling to eliminate modulo bias |
| **Flaw-4**: Client-side token generation | Server-side only — tokens never leave the server until embedded in the QR |
| **Flaw-5**: Missing status notification | Desktop toast: _"Device [IP] authenticated via QR (Safari). Not you? [Revoke]"_ — real-time QRLjacking detection |
| **Flaw-6**: Inadequate session binding | IP + User-Agent stored for audit. Manual session revocation via API. HttpOnly + Secure + SameSite=lax cookies |
#### Timing-Safe Lookup
@@ -471,23 +475,23 @@ When someone authenticates via QR, the desktop shows a notification toast with t
#### Threat Coverage
| Threat | Why it doesn't work |
|--------|-------------------|
| **QR screenshot shared** | Single-use: consumed on first scan. 60s TTL: expired before the attacker can act. Desktop notification alerts you immediately. |
| **Replay attack** | Atomic single-use consumption + 60s TTL. Old URLs always return 401. |
| **Cloudflare edge logs** | Short code is an opaque 6-char lookup key, not the real 256-bit token. Single-use means replaying from logs always fails. |
| **Brute force** | 56.8 billion combinations, ~2 valid at any time, dual-layer rate limiting blocks well before statistical feasibility. |
| **QRLjacking** | 60s rotation forces real-time relay. Desktop toast provides instant detection. Self-hosted single-user context makes phishing implausible. |
| **Timing attack** | Hash-based Map lookup — no string comparison timing leak. |
| **Session cookie theft** | HttpOnly + Secure + SameSite=lax + 24h TTL. Manual revocation at `POST /api/auth/revoke`. |
| Threat | Why it doesn't work |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **QR screenshot shared** | Single-use: consumed on first scan. 60s TTL: expired before the attacker can act. Desktop notification alerts you immediately. |
| **Replay attack** | Atomic single-use consumption + 60s TTL. Old URLs always return 401. |
| **Cloudflare edge logs** | Short code is an opaque 6-char lookup key, not the real 256-bit token. Single-use means replaying from logs always fails. |
| **Brute force** | 56.8 billion combinations, ~2 valid at any time, dual-layer rate limiting blocks well before statistical feasibility. |
| **QRLjacking** | 60s rotation forces real-time relay. Desktop toast provides instant detection. Self-hosted single-user context makes phishing implausible. |
| **Timing attack** | Hash-based Map lookup — no string comparison timing leak. |
| **Session cookie theft** | HttpOnly + Secure + SameSite=lax + 24h TTL. Manual revocation at `POST /api/auth/revoke`. |
#### How It Compares
| Platform | Model | Comparison |
|----------|-------|------------|
| **Discord** | Long-lived token, no confirmation, [repeatedly exploited](https://owasp.org/www-community/attacks/Qrljacking) | Codeman: single-use + TTL + notification |
| **WhatsApp Web** | Phone confirms "Link device?", ~60s rotation | Comparable rotation; WhatsApp adds explicit confirmation (acceptable tradeoff for single-user) |
| **Signal** | Ephemeral public key, E2E encrypted channel | Stronger crypto, but [exploited by Russian state actors in 2025](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) via social engineering despite it |
| Platform | Model | Comparison |
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Discord** | Long-lived token, no confirmation, [repeatedly exploited](https://owasp.org/www-community/attacks/Qrljacking) | Codeman: single-use + TTL + notification |
| **WhatsApp Web** | Phone confirms "Link device?", ~60s rotation | Comparable rotation; WhatsApp adds explicit confirmation (acceptable tradeoff for single-user) |
| **Signal** | Ephemeral public key, E2E encrypted channel | Stronger crypto, but [exploited by Russian state actors in 2025](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) via social engineering despite it |
> Full design rationale, security analysis, and implementation details: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
@@ -495,20 +499,20 @@ When someone authenticates via QR, the desktop shows a notification toast with t
## Security
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control *who* that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control _who_ that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
### Network & access
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` *starts but prints a loud warning* with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers *immediately* even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
### Always-on browser hardening (v0.9.5)
These run for **every** request — before auth, even on the default no-password loopback install:
- **Host-header allowlist → blocks DNS rebinding.** A custom domain rebound to `127.0.0.1` is rejected with `403 host not allowed` before any handler runs. Allowed: `localhost`, any IP literal, the bind host, `.ts.net` / `.trycloudflare.com` / `.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS` (add custom reverse-proxy domains here — comma-separated; exact host or leading-dot `.suffix` for subdomains)
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A *missing* Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A _missing_ Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
- **Raw `text/plain` bodies.** The global parser no longer JSON-parses `text/plain`, closing the CORS "simple request" CSRF vector where a cross-site `fetch` could smuggle JSON into a write route with no preflight
- **WebSocket origin validation.** The terminal WS upgrade runs the same Host + Origin check and closes with code `4003` on failure (anti-CSWSH)
- **XSS-escaped agent output.** AI-derived strings (tool names, command arguments, subagent descriptions) are HTML-escaped at every injection site before rendering in the subagent / activity panels
@@ -546,37 +550,38 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
> Ctrl bindings also accept Cmd on macOS.
| Shortcut | Action |
|----------|--------|
| `Ctrl/Cmd+W` | Kill active session |
| `Ctrl/Cmd+Tab` | Next session |
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl/Cmd +` / `-` | Font size |
| `Ctrl/Cmd+?` | Keyboard help |
| `Shift+Enter` | Insert newline (sent to terminal) |
| `Escape` | Close panels & modals |
| Shortcut | Action |
| ------------------------------- | ------------------------------------------------------------- |
| `Ctrl/Cmd+W` | Kill active session |
| `Ctrl/Cmd/Option+K` | Find open session or start a new one |
| `Ctrl/Cmd+Tab` | Next session |
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl/Cmd +` / `-` | Font size |
| `Ctrl/Cmd+?` | Keyboard help |
| `Shift+Enter` | Insert newline (sent to terminal) |
| `Escape` | Close panels & modals |
---
## Driving Codeman from an Agent — Programmatic Guide
For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running *inside* a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too.
For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running _inside_ a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too.
### Detect that you're inside Codeman
When a CLI runs in a Codeman-managed session, these environment variables are set — read them instead of hardcoding anything:
| Variable | Meaning |
|----------|---------|
| `CODEMAN_MUX=1` | You're in a managed tmux session. **Never** `tmux kill-session` / `pkill claude` / `pkill tmux` — you'll kill yourself or a sibling. |
| `CODEMAN_API_URL` | Base URL of the API (e.g. `https://127.0.0.1:3000`). Use it for every call below. |
| `CODEMAN_SESSION_ID` | *Your own* session id. Use it to avoid acting on yourself. |
| `CODEMAN_HOOK_SECRET_FILE` | Path to the hook secret (required on `/api/hook-event` while a managed tunnel is up). |
| Variable | Meaning |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `CODEMAN_MUX=1` | You're in a managed tmux session. **Never** `tmux kill-session` / `pkill claude` / `pkill tmux` — you'll kill yourself or a sibling. |
| `CODEMAN_API_URL` | Base URL of the API (e.g. `https://127.0.0.1:3000`). Use it for every call below. |
| `CODEMAN_SESSION_ID` | _Your own_ session id. Use it to avoid acting on yourself. |
| `CODEMAN_HOOK_SECRET_FILE` | Path to the hook secret (required on `/api/hook-event` while a managed tunnel is up). |
### Rules of the road (read before you POST)
@@ -640,7 +645,7 @@ codeman ralph start --min-hours 8 # (r) launch the autonomous loop
codeman attach <path> # attach a Claude hook context
```
### Hooks (events flowing *back* to Codeman)
### Hooks (events flowing _back_ to Codeman)
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
@@ -653,62 +658,69 @@ Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_pro
REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
### Sessions
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/sessions` | List all |
| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) |
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) |
| `GET` | `/api/sessions/:id/output` | Read terminal output |
| `DELETE` | `/api/sessions/:id` | Delete session |
| Method | Endpoint | Description |
| -------- | -------------------------- | ---------------------------------------------------------------------------------- |
| `GET` | `/api/sessions` | List all |
| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) |
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) |
| `GET` | `/api/sessions/:id/output` | Read terminal output |
| `DELETE` | `/api/sessions/:id` | Delete session |
### Respawn
| Method | Endpoint | Description |
|--------|----------|-------------|
| Method | Endpoint | Description |
| ------ | ---------------------------------- | -------------------------- |
| `POST` | `/api/sessions/:id/respawn/enable` | Enable with config + timer |
| `POST` | `/api/sessions/:id/respawn/stop` | Stop controller |
| `PUT` | `/api/sessions/:id/respawn/config` | Update config |
| `POST` | `/api/sessions/:id/respawn/stop` | Stop controller |
| `PUT` | `/api/sessions/:id/respawn/config` | Update config |
### Ralph / Todo
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
| Method | Endpoint | Description |
| ------ | -------------------------------- | ---------------------- |
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
### Orchestrator
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/orchestrator/start` | Start orchestration from a goal |
| `POST` | `/api/orchestrator/approve` | Approve the generated plan |
| `GET` | `/api/orchestrator/status` | Current phase + progress |
| `POST` | `/api/orchestrator/stop` | Stop and clean up |
| Method | Endpoint | Description |
| ------ | --------------------------- | ------------------------------- |
| `POST` | `/api/orchestrator/start` | Start orchestration from a goal |
| `POST` | `/api/orchestrator/approve` | Approve the generated plan |
| `GET` | `/api/orchestrator/status` | Current phase + progress |
| `POST` | `/api/orchestrator/stop` | Stop and clean up |
### Cron (scheduled jobs)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` / `POST` | `/api/cron/jobs` | List / create cron jobs |
| `PUT` / `DELETE` | `/api/cron/jobs/:id` | Update / delete a job |
| `PUT` | `/api/cron/jobs/:id/enabled` | Enable / disable |
| `POST` | `/api/cron/jobs/:id/run` | Run now |
| `GET` | `/api/cron/jobs/:id/runs` | Run history |
| Method | Endpoint | Description |
| ---------------- | ---------------------------- | ----------------------- |
| `GET` / `POST` | `/api/cron/jobs` | List / create cron jobs |
| `PUT` / `DELETE` | `/api/cron/jobs/:id` | Update / delete a job |
| `PUT` | `/api/cron/jobs/:id/enabled` | Enable / disable |
| `POST` | `/api/cron/jobs/:id/run` | Run now |
| `GET` | `/api/cron/jobs/:id/runs` | Run history |
### Subagents
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/subagents` | List all background agents |
| `GET` | `/api/subagents/:id` | Agent info and status |
| `GET` | `/api/subagents/:id/transcript` | Full activity transcript |
| `DELETE` | `/api/subagents/:id` | Kill agent process |
| Method | Endpoint | Description |
| -------- | ------------------------------- | -------------------------- |
| `GET` | `/api/subagents` | List all background agents |
| `GET` | `/api/subagents/:id` | Agent info and status |
| `GET` | `/api/subagents/:id/transcript` | Full activity transcript |
| `DELETE` | `/api/subagents/:id` | Kill agent process |
### System
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/events` | SSE stream |
| `GET` | `/api/status` | Full app state |
| `POST` | `/api/hook-event` | Hook callbacks |
| `GET` | `/api/system/update/check` | Check for a new release |
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
| Method | Endpoint | Description |
| ------ | ------------------------------- | ---------------------------------------------- |
| `GET` | `/api/events` | SSE stream |
| `GET` | `/api/status` | Full app state |
| `POST` | `/api/hook-event` | Hook callbacks |
| `GET` | `/api/system/update/check` | Check for a new release |
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
---
@@ -786,14 +798,14 @@ See [CLAUDE.md](./CLAUDE.md) for full documentation.
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
| Phase | What changed | Impact |
|-------|-------------|--------|
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
| **Route extraction** | `server.ts` split into 15 domain route modules + auth middleware + port interfaces | **−67%** server.ts LOC (6,736 → 2,254) |
| **Domain splitting** | `types.ts` → 16 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
| **Frontend modules** | `app.js` → 18 extracted modules across infra, domain & feature layers | app.js core down to **~3.4K LOC** |
| **Config consolidation** | ~70 scattered magic numbers → 10 domain-focused config files | Zero cross-file duplicates |
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
| Phase | What changed | Impact |
| ------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
| **Route extraction** | `server.ts` split into 15 domain route modules + auth middleware + port interfaces | **−67%** server.ts LOC (6,736 → 2,254) |
| **Domain splitting** | `types.ts` → 16 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
| **Frontend modules** | `app.js` → 18 extracted modules across infra, domain & feature layers | app.js core down to **~3.4K LOC** |
| **Config consolidation** | ~70 scattered magic numbers → 10 domain-focused config files | Zero cross-file duplicates |
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
+250 -22
View File
@@ -305,6 +305,134 @@ function parseSessionPrefix(name) {
return null;
}
const DEFAULT_SHORTCUTS = [
{
id: 'show-shortcuts',
group: 'Panels',
label: 'Show Shortcuts',
bindings: [
{ modifiers: ['ctrl'], key: '?', code: 'Slash' },
{ modifiers: ['ctrl', 'shift'], key: '?' },
{ modifiers: ['alt'], key: '?', code: 'Slash' },
],
action: 'showShortcutOverlay',
},
{
id: 'close-session',
group: 'Session',
label: 'Close Session',
bindings: [{ modifiers: ['ctrl'], key: 'w' }],
action: 'killActiveSession',
},
{
id: 'next-session',
group: 'Session',
label: 'Next Session',
bindings: [{ modifiers: ['ctrl'], key: 'Tab' }],
action: 'nextSession',
},
{
id: 'clear-terminal',
group: 'Terminal',
label: 'Clear Terminal',
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
action: 'clearTerminal',
},
{
id: 'increase-font',
group: 'Terminal',
label: 'Increase Font',
bindings: [
{ modifiers: ['ctrl'], key: '=', code: 'Equal' },
{ modifiers: ['ctrl'], key: '+', code: 'Equal' },
],
action: 'increaseFontSize',
},
{
id: 'decrease-font',
group: 'Terminal',
label: 'Decrease Font',
bindings: [{ modifiers: ['ctrl'], key: '-', code: 'Minus' }],
action: 'decreaseFontSize',
},
{
id: 'voice-input',
group: 'Terminal',
label: 'Voice Input',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'V' }],
action: 'toggleVoiceInput',
},
{
id: 'restore-terminal-size',
group: 'Terminal',
label: 'Restore Terminal Size',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'R' }],
action: 'restoreTerminalSize',
},
{
id: 'move-tab-left',
group: 'Tabs',
label: 'Move Active Tab Left',
bindings: [{ modifiers: ['ctrl', 'shift'], key: '{', code: 'BracketLeft' }],
action: 'moveActiveTabLeft',
},
{
id: 'move-tab-right',
group: 'Tabs',
label: 'Move Active Tab Right',
bindings: [{ modifiers: ['ctrl', 'shift'], key: '}', code: 'BracketRight' }],
action: 'moveActiveTabRight',
},
{
id: 'command-palette',
group: 'Session',
label: 'Find Open Session',
bindings: [
{ modifiers: ['ctrl'], key: 'k', code: 'KeyK' },
{ modifiers: ['meta'], key: 'k', code: 'KeyK' },
{ modifiers: ['alt'], key: 'k', code: 'KeyK' },
],
action: 'openCommandPalette',
},
{
id: 'previous-next-session',
group: 'Session',
label: 'Previous / Next Session',
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
},
{
id: 'switch-tab-n',
group: 'Session',
label: 'Switch to Tab N',
displayBindings: ['Alt/Option+1-9'],
},
{
id: 'focus-tabs',
group: 'Tabs',
label: 'Focus Tabs',
displayBindings: ['ArrowLeft', 'ArrowRight', 'Home', 'End'],
},
{
id: 'activate-focused-tab',
group: 'Tabs',
label: 'Activate Focused Tab',
displayBindings: ['Enter', 'Space'],
},
{
id: 'insert-newline',
group: 'Terminal',
label: 'Insert Newline',
displayBindings: ['Shift+Enter', 'Ctrl+Enter'],
},
{
id: 'close-panels',
group: 'Panels',
label: 'Close Panels',
displayBindings: ['Escape'],
},
];
// ═══════════════════════════════════════════════════════════════
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
@@ -807,32 +935,43 @@ class CodemanApp {
// ═══════════════════════════════════════════════════════════════
setupEventListeners() {
// Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks.
// Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd),
// shift? (require Shift), action }.
const SHORTCUTS = [
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
];
// Action name → handler map for the shortcut registry (DEFAULT_SHORTCUTS +
// user overrides from settings.shortcutOverrides, merged by
// getShortcutRegistry()). The command palette chord is deliberately NOT in
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
// focus-target awareness (it must fire from the terminal but not from inputs).
const SHORTCUT_ACTIONS = {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
};
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
// Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return;
if (this.shouldOpenCommandPaletteFromShortcut?.(e)) {
e.preventDefault();
this.openCommandPalette();
return;
}
// Escape - close panels and modals (different logic: no preventDefault, no return)
if (e.key === 'Escape') {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
}
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
@@ -862,14 +1001,18 @@ class CodemanApp {
}
}
// Match against shortcut table
for (const s of SHORTCUTS) {
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
if (keyMatch && ctrlMatch && shiftMatch) {
// Match against the shortcut registry so user rebinds and per-shortcut
// disables (App Settings → Shortcuts) take effect. Every dispatchable
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
// typing exits early without touching the registry.
if (!e.ctrlKey && !e.metaKey && !e.altKey) return;
for (const shortcut of this.getShortcutRegistry()) {
if (shortcut.disabled || !shortcut.action) continue;
const action = SHORTCUT_ACTIONS[shortcut.action];
if (!action) continue;
if (this.matchesShortcutEvent(e, shortcut)) {
e.preventDefault();
s.action();
action();
return;
}
}
@@ -4252,6 +4395,91 @@ class CodemanApp {
}
}
// ─── Shortcut Registry ───────────────────────────────────────────────────────
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
getShortcutRegistry() {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = settings.shortcutOverrides || {};
return DEFAULT_SHORTCUTS.map((shortcut) => {
const override = shortcutOverrides[shortcut.id];
if (!override) return shortcut;
// Only binding-shaped fields may come from storage — id/label/group/action
// stay trusted so persisted data can never redirect a shortcut's action or
// spoof another row in the settings/overlay renderers.
const merged = { ...shortcut };
if (Array.isArray(override.bindings)) {
merged.bindings = override.bindings;
delete merged.displayBindings; // show the override, not the stale default label
}
if (typeof override.disabled === 'boolean') merged.disabled = override.disabled;
return merged;
});
}
matchesShortcutEvent(e, shortcut) {
if (!shortcut || !Array.isArray(shortcut.bindings)) return false;
return shortcut.bindings.some((binding) => {
const mods = binding.modifiers || [];
// Ctrl and Cmd are interchangeable as the primary modifier (parity with
// the legacy shortcut table), but every OTHER pressed modifier must be
// declared by the binding — a plain Ctrl+K binding must not also swallow
// Ctrl+Shift+K (the Firefox devtools chord).
const wantsPrimary = mods.includes('ctrl') || mods.includes('meta');
if (wantsPrimary !== !!(e.ctrlKey || e.metaKey)) return false;
if (mods.includes('shift') !== !!e.shiftKey) return false;
if (mods.includes('alt') !== !!e.altKey) return false;
// Match the physical key when the binding pins one (layout-independent),
// or the produced character otherwise (layout-dependent keys like '+').
if (binding.code && e.code === binding.code) return true;
if (binding.key && typeof e.key === 'string' && e.key.toLowerCase() === binding.key.toLowerCase()) return true;
return false;
});
}
// ─── Shortcut Overlay Modal ───────────────────────────────────────────────────
// Ctrl/Alt+? opens a floating overlay listing all keyboard shortcuts, grouped
// by category. Uses the merged registry so user overrides are reflected.
showShortcutOverlay() {
const modal = document.getElementById('shortcutOverlayModal');
if (!modal) return;
this.renderShortcutOverlay();
modal.classList.add('active');
modal.focus?.();
}
renderShortcutOverlay() {
const list = document.getElementById('shortcutOverlayList');
if (!list) return;
const registry = this.getShortcutRegistry();
const groups = {};
for (const shortcut of registry) {
const g = shortcut.group || 'General';
if (!groups[g]) groups[g] = [];
groups[g].push(shortcut);
}
const fmtBindings = (s) => {
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
if (!s.bindings) return '';
return s.bindings.map((b) => {
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
}).join(' / ');
};
list.innerHTML = Object.entries(groups).map(([group, items]) =>
`<div class="shortcut-overlay-group"><div class="shortcut-overlay-group-label">${escapeHtml(group)}</div>` +
items.map((s) => `<div class="shortcut-overlay-row"><span class="shortcut-overlay-label">${escapeHtml(s.label)}</span><span class="shortcut-overlay-keys">${fmtBindings(s)}</span></div>`).join('') +
`</div>`
).join('');
}
closeShortcutOverlay() {
const modal = document.getElementById('shortcutOverlayModal');
if (modal) modal.classList.remove('active');
}
}
// ═══════════════════════════════════════════════════════════════
+122 -16
View File
@@ -358,7 +358,7 @@
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
<div class="history-list" id="historyList"></div>
</div>
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
<p class="welcome-hint">Or click Run to start</p>
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div>
</div>
@@ -427,7 +427,7 @@
<!-- Run AI -->
<div class="toolbar-group">
<div class="run-btn-group">
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run (Ctrl+Enter)">
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span id="runBtnLabel">Run</span>
</button>
@@ -469,7 +469,22 @@
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
</div>
<div class="case-select-group">
<select id="quickStartCase" class="toolbar-select" title="Select case">
<div class="case-combobox" id="quickStartCasePicker">
<input
type="text"
id="quickStartCaseSearch"
class="case-combobox-input"
role="combobox"
aria-controls="quickStartCaseList"
aria-expanded="false"
aria-autocomplete="list"
autocomplete="off"
spellcheck="false"
title="Select case"
>
<div id="quickStartCaseList" class="case-combobox-list hidden" role="listbox"></div>
</div>
<select id="quickStartCase" class="toolbar-select case-native-select" title="Select case" aria-hidden="true" tabindex="-1">
<option value="testcase">testcase</option>
</select>
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
@@ -555,19 +570,49 @@
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">&times;</button>
</div>
<div class="modal-body">
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Help</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
<div><kbd>Escape</kbd></div><div>Close Panels</div>
</div>
<section class="shortcut-section">
<h4>Session</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
</div>
</section>
<section class="shortcut-section">
<h4>Tabs</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>{</kbd></div><div>Move Active Tab Left</div>
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
<div><kbd>ArrowRight</kbd></div><div>Focus Next Tab</div>
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
</div>
</section>
<section class="shortcut-section">
<h4>Terminal</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></div><div>Restore Terminal Size</div>
<div><kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</div>
</div>
</section>
<section class="shortcut-section">
<h4>Panels</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
<div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
<div><kbd>Escape</kbd></div><div>Close Panels</div>
</div>
</section>
</div>
</div>
</div>
@@ -1091,6 +1136,7 @@
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
<button class="modal-tab-btn" data-tab="settings-voice">Voice</button>
<button class="modal-tab-btn" data-tab="settings-shortcuts">Shortcuts</button>
</div>
<div class="modal-body">
<!-- Display Tab -->
@@ -1704,6 +1750,19 @@
</div>
</div>
</div>
<!-- Shortcuts tab -->
<div class="modal-tab-content hidden" id="settings-shortcuts">
<div class="settings-grid">
<div class="settings-section-header" style="grid-column: 1 / -1;">Keyboard Shortcuts</div>
<p class="form-hint" style="grid-column: 1 / -1; margin: 0 0 0.5rem;">
Customize keyboard shortcuts. Click the binding to capture a new key combination.
</p>
<div id="appSettingsShortcutsList" style="grid-column: 1 / -1;">
<!-- Populated by app.renderShortcutSettingsList() -->
</div>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
@@ -1712,6 +1771,23 @@
</div>
</div>
<!-- Shortcut Overlay Modal -->
<div class="modal shortcut-overlay-modal" id="shortcutOverlayModal" tabindex="-1">
<div class="modal-backdrop" onclick="app.closeShortcutOverlay()"></div>
<div class="modal-content">
<div class="modal-header">
<span class="modal-title">Keyboard Shortcuts</span>
<button class="modal-close" onclick="app.closeShortcutOverlay()" aria-label="Close">&#x2715;</button>
</div>
<div class="modal-body">
<div id="shortcutOverlayList"></div>
<div class="shortcut-overlay-footer">
<button class="btn btn-sm" onclick="app.closeShortcutOverlay(); app.showHelp()">Full shortcut reference</button>
</div>
</div>
</div>
</div>
<!-- Create Case Modal -->
<div class="modal" id="createCaseModal">
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
@@ -2111,6 +2187,36 @@
</div>
</div>
<!-- Command Palette Modal -->
<div class="modal command-palette-modal" id="commandPaletteModal">
<div class="modal-backdrop" onclick="app.closeCommandPalette()"></div>
<div class="command-palette-shell" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
<div class="command-palette-input-row">
<span class="command-palette-search-icon" aria-hidden="true">⌕</span>
<input type="search" id="commandPaletteSearch" class="command-palette-search" placeholder="Search open sessions or start a new one" autocomplete="off" maxlength="160" aria-labelledby="commandPaletteTitle">
<kbd>Esc</kbd>
</div>
<div class="command-palette-label" id="commandPaletteTitle">Open sessions</div>
<div id="commandPaletteList" class="command-palette-list"></div>
</div>
</div>
<!-- Session Manager Modal -->
<div class="modal" id="sessionManagerModal">
<div class="modal-backdrop" onclick="app.closeSessionManager()"></div>
<div class="modal-content session-manager-modal">
<div class="modal-header">
<h3>Sessions</h3>
<button class="modal-close" onclick="app.closeSessionManager()" aria-label="Close session manager">&times;</button>
</div>
<div class="modal-body">
<input type="search" id="sessionManagerSearch" class="search-input" placeholder="Search sessions by name, prompt, or path&hellip;" autocomplete="off" maxlength="200">
<div id="sessionManagerList" class="session-manager-list"></div>
</div>
</div>
</div>
<!-- Token Stats Modal -->
<div class="modal" id="tokenStatsModal">
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
+26 -1
View File
@@ -162,7 +162,11 @@ html.mobile-init .file-browser-panel {
}
.case-select-group {
max-width: 150px;
max-width: none;
}
.case-combobox {
width: 160px;
}
.toolbar-select {
@@ -194,6 +198,27 @@ html.mobile-init .file-browser-panel {
z-index: 1300;
}
.command-palette-modal {
padding: 10vh 0.75rem 0;
}
.command-palette-shell {
width: 100%;
max-height: 74vh;
}
.command-palette-input-row {
grid-template-columns: 20px minmax(0, 1fr);
}
.command-palette-input-row kbd {
display: none;
}
.command-palette-item {
min-height: 56px;
}
.modal-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
+393
View File
@@ -250,6 +250,399 @@ Object.assign(CodemanApp.prototype, {
this.openImagePopup(data);
},
// ═══════════════════════════════════════════════════════════════
// Command Palette (COD-153)
// Fast Cmd/Ctrl+K switcher for currently open sessions, plus launch-new.
// ═══════════════════════════════════════════════════════════════
shouldOpenCommandPaletteFromShortcut(e) {
if (!e) return false;
// Every palette chord requires Ctrl/Cmd/Alt (capture enforces the same for
// rebinds), so plain typing exits before any registry work — this runs on
// the document AND xterm keydown hot paths.
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
// Registry-aware chord check (COD-157): honors a rebound or disabled
// palette shortcut. Falls back to the default Ctrl/Cmd/Alt+K chord when the
// registry isn't available (isolated test harnesses).
const registryAvailable =
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
const palette = registryAvailable
? this.getShortcutRegistry().find((s) => s.id === 'command-palette')
: null;
if (palette) {
if (palette.disabled || !this.matchesShortcutEvent(e, palette)) return false;
} else {
const key = (e.key || '').toLowerCase();
if (key !== 'k' && e.code !== 'KeyK') return false;
// Don't hijack chords with extra modifiers (Ctrl+Shift+K is the Firefox
// devtools console; matchesShortcutEvent applies the same rule above).
if (e.shiftKey) return false;
}
const target = e.target;
if (!target) return true;
const tagName = (target.tagName || '').toUpperCase();
const className = typeof target.className === 'string' ? target.className : '';
const isXtermHelper =
target.classList?.contains?.('xterm-helper-textarea') || className.includes('xterm-helper-textarea');
if (isXtermHelper) return true;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return false;
if (target.isContentEditable) return false;
if (typeof target.closest === 'function' && target.closest('[contenteditable="true"]')) return false;
return true;
},
openCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
const search = document.getElementById('commandPaletteSearch');
if (!modal || !search) return;
this.commandPaletteActiveIndex = 0;
search.value = '';
modal.classList.add('active');
this._wireCommandPalette();
this.renderCommandPalette();
search.focus();
search.select?.();
},
closeCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
if (modal) modal.classList.remove('active');
},
_wireCommandPalette() {
if (this._commandPaletteWired) return;
this._commandPaletteWired = true;
const modal = document.getElementById('commandPaletteModal');
const search = document.getElementById('commandPaletteSearch');
const list = document.getElementById('commandPaletteList');
search?.addEventListener('input', () => {
this.commandPaletteActiveIndex = 0;
this.renderCommandPalette();
});
search?.addEventListener('keydown', async (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.moveCommandPaletteSelection(1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.moveCommandPaletteSelection(-1);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
await this.activateCommandPaletteItem();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
this.closeCommandPalette();
}
});
modal?.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.closeCommandPalette();
}
});
list?.addEventListener?.('click', (e) => {
const row = e.target?.closest?.('[data-command-index]');
if (!row) return;
this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0;
void this.activateCommandPaletteItem();
});
},
buildCommandPaletteItems(query = '') {
const needle = query.trim().toLowerCase();
const orderedIds = [
...(Array.isArray(this.sessionOrder) ? this.sessionOrder : []),
...Array.from(this.sessions?.keys?.() || []).filter((id) => !this.sessionOrder?.includes?.(id)),
];
const seen = new Set();
const sessionItems = [];
for (const sessionId of orderedIds) {
if (seen.has(sessionId)) continue;
seen.add(sessionId);
const session = this.sessions?.get?.(sessionId);
if (!session) continue;
const title = this.getSessionName?.(session) || session.name || session.title || sessionId.slice(0, 8);
const subtitleParts = [session.workingDir, session.mode, session.status].filter(Boolean);
const haystack = [title, session.workingDir, session.mode, session.status, sessionId].filter(Boolean).join(' ').toLowerCase();
if (needle && !haystack.includes(needle)) continue;
sessionItems.push({
id: `session:${sessionId}`,
type: 'session',
sessionId,
title,
subtitle: subtitleParts.join(' · '),
});
}
sessionItems.push(this._buildCommandPaletteNewSessionItem(query));
sessionItems.push({ id: 'browse-sessions', type: 'browse-sessions', title: 'Browse all sessions…', subtitle: 'Open Session Manager' });
return sessionItems;
},
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
type: 'new-session',
caseName,
title: 'New session',
subtitle: `Run ${labels[mode] || mode} in ${caseName}`,
};
},
_findCommandPaletteCaseMatch(query = '') {
const needle = query.trim().toLowerCase();
if (!needle || !Array.isArray(this.cases)) return null;
const scoreCase = (caseItem) => {
const name = String(caseItem?.name || '').trim();
if (!name) return 0;
const haystack = [
name,
caseItem?.path,
caseItem?.casePath,
caseItem?.workingDir,
caseItem?.remote?.path,
caseItem?.remote?.hostId,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
const lowerName = name.toLowerCase();
if (lowerName === needle) return 100;
if (lowerName.startsWith(needle)) return 90;
if (lowerName.includes(needle)) return 80;
if (haystack.includes(needle)) return 60;
return 0;
};
let best = null;
let bestScore = 0;
for (const caseItem of this.cases) {
const score = scoreCase(caseItem);
if (score > bestScore) {
best = caseItem;
bestScore = score;
}
}
return best?.name || null;
},
renderCommandPalette() {
const search = document.getElementById('commandPaletteSearch');
const list = document.getElementById('commandPaletteList');
if (!list) return;
const query = search?.value || '';
const items = this.buildCommandPaletteItems(query);
this.commandPaletteItems = items;
this.commandPaletteActiveIndex = Math.max(0, Math.min(this.commandPaletteActiveIndex || 0, items.length - 1));
list.innerHTML = items
.map((item, index) => {
const active = index === this.commandPaletteActiveIndex ? ' active' : '';
const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›';
const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : '';
return `
<button class="command-palette-item${active}${browse}" type="button" data-command-index="${index}">
<span class="command-palette-icon" aria-hidden="true">${icon}</span>
<span class="command-palette-text">
<span class="command-palette-title">${escapeHtml(item.title)}</span>
<span class="command-palette-subtitle">${escapeHtml(item.subtitle || '')}</span>
</span>
</button>
`;
})
.join('');
},
moveCommandPaletteSelection(delta) {
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
if (!items.length) return;
this.commandPaletteActiveIndex = (this.commandPaletteActiveIndex + delta + items.length) % items.length;
this.renderCommandPalette();
},
async activateCommandPaletteItem(index = this.commandPaletteActiveIndex || 0) {
const item = (this.commandPaletteItems || [])[index];
if (!item) return;
this.closeCommandPalette();
if (item.type === 'session' && item.sessionId) {
await this.selectSession(item.sessionId);
return;
}
if (item.type === 'browse-sessions') {
this.openSessionManager();
return;
}
if (item.type === 'new-session') {
const caseSelect = document.getElementById('quickStartCase');
if (caseSelect && item.caseName) {
if (
caseSelect.tagName === 'SELECT' &&
typeof caseSelect.appendChild === 'function' &&
!Array.from(caseSelect.options || []).some((option) => option.value === item.caseName)
) {
const option = document.createElement('option');
option.value = item.caseName;
option.textContent = item.caseName;
caseSelect.appendChild(option);
}
// selectQuickStartCase keeps the searchable combobox, dir display, and
// persisted last-used case in sync with the palette's pick (COD-151);
// fall back to a bare value set when the picker mixin isn't loaded.
if (typeof this.selectQuickStartCase === 'function') {
this.selectQuickStartCase(item.caseName);
} else {
caseSelect.value = item.caseName;
}
}
await this.run();
}
},
// ═══════════════════════════════════════════════════════════════
// Session Manager Modal (COD-121)
// Unified session list (GET /api/sessions/unified) reachable mid-session,
// with a server-side search box. Reuses the history item renderer; clicking
// a live row switches to it, a history row resumes the conversation.
// ═══════════════════════════════════════════════════════════════
async openSessionManager() {
const modal = document.getElementById('sessionManagerModal');
if (modal) {
modal.classList.add('active');
// Escape closes the modal even while focus is in the search input. A
// modal-scoped listener is robust regardless of the global Escape chain
// (which runs other close handlers first and can short-circuit). Wire once.
if (!this._sessionManagerEscWired) {
this._sessionManagerEscWired = true;
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this.closeSessionManager();
}
});
}
}
// Ensure cases are loaded so item subtitles can show "#caseName" labels.
// Mirror loadHistorySessions(): prefer already-loaded this.cases.
if (!Array.isArray(this.cases) || this.cases.length === 0) {
try {
const r = await fetch('/api/cases');
const d = r.ok ? await r.json() : null;
this.cases = d?.data || [];
} catch {
this.cases = this.cases || [];
}
}
const search = document.getElementById('sessionManagerSearch');
if (search) {
// Wire the debounced search input once (lazy — the element exists by
// the time the modal is first opened, and mixin methods are bound).
if (!this._sessionManagerSearchWired) {
this._sessionManagerSearchWired = true;
search.addEventListener('input', () => {
const value = search.value.trim();
this._debouncedCall('sessionManagerSearch', () => this._loadSessionManagerList(value), 200);
});
}
search.value = '';
search.focus();
}
await this._loadSessionManagerList('');
},
closeSessionManager() {
const modal = document.getElementById('sessionManagerModal');
if (modal) modal.classList.remove('active');
},
/** Replace the Session Manager list body with a single status line. */
_setSessionManagerMessage(list, message) {
list.replaceChildren();
const line = document.createElement('p');
line.className = 'empty-message';
line.textContent = message;
list.appendChild(line);
},
async _loadSessionManagerList(q = '') {
this._sessionManagerQuery = q;
const list = document.getElementById('sessionManagerList');
if (!list) return;
try {
const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
const res = await fetch(url);
const data = await res.json().catch(() => null);
// ApiResponse envelope: { success: true, data: { sessions, total } }.
// Surface failures instead of rendering them as an empty result set.
if (!res.ok || !data || data.success === false || !data.data) {
this._setSessionManagerMessage(list, data?.error || `Failed to load sessions (HTTP ${res.status})`);
return;
}
const sessions = data.data.sessions || [];
list.replaceChildren();
if (sessions.length === 0) {
this._setSessionManagerMessage(list, q ? 'No sessions match your search' : 'No sessions found');
return;
}
for (const s of sessions) {
// Adapt UnifiedSessionItem (lastActivityAt epoch-ms, optional fields) to
// the history-record shape _buildHistoryItem renders (lastModified date
// string, sizeBytes, firstPrompt).
const record = {
sessionId: s.sessionId,
workingDir: s.workingDir || '',
sizeBytes: s.sizeBytes ?? 0,
lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(),
firstPrompt: s.firstPrompt || s.name || '',
};
const isLive = !!this.sessions?.has?.(s.sessionId);
const item = this._buildHistoryItem(record, this.cases, {
showViewAll: false,
onActivate: () => {
this.closeSessionManager();
if (isLive) {
void this.selectSession(s.sessionId);
} else if (record.workingDir) {
// History rows are keyed by the Claude conversation UUID; resumed
// sessions carry theirs separately as claudeSessionId.
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir);
}
},
});
list.appendChild(item);
}
} catch (err) {
console.error('[_loadSessionManagerList]', err);
this._setSessionManagerMessage(list, 'Failed to load sessions');
}
},
// ═══════════════════════════════════════════════════════════════
// Away Digest Modal
+216 -29
View File
@@ -44,6 +44,203 @@ Object.assign(CodemanApp.prototype, {
// Quick Start
// ═══════════════════════════════════════════════════════════════
formatCasePickerLabel(c) {
return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || '';
},
buildCasePickerOptions(cases = []) {
const normalized = [];
const seen = new Set();
for (const c of cases) {
if (!c?.name || seen.has(c.name)) continue;
seen.add(c.name);
normalized.push(c);
}
if (!seen.has('testcase')) {
normalized.push({ name: 'testcase' });
}
return normalized
.map(c => {
const label = this.formatCasePickerLabel(c);
const searchText = [
c.name,
label,
c.path,
c.location,
c.remote?.hostId,
c.remote?.label,
c.remote?.path
].filter(Boolean).join(' ').toLowerCase();
return { name: c.name, label, case: c, searchText };
})
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base', numeric: true }));
},
filterCasePickerOptions(options, query) {
const terms = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return options;
return options.filter(option => terms.every(term => option.searchText.includes(term)));
},
getCasePickerOptions() {
return this.buildCasePickerOptions(this.cases || []);
},
updateCasePickerInput(caseName) {
const input = document.getElementById('quickStartCaseSearch');
if (!input) return;
const option = this.getCasePickerOptions().find(item => item.name === caseName);
input.value = option?.label || caseName || 'testcase';
input.title = option?.label || input.value;
},
renderQuickStartCaseSelectOptions(select, options) {
if (!select) return;
select.innerHTML = options
.map(option => `<option value="${escapeHtml(option.name)}">${escapeHtml(option.label)}</option>`)
.join('');
},
openCasePicker(filter = '') {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
if (!input || !list) return;
this._casePickerOpen = true;
this._casePickerFilter = filter;
this._casePickerActiveIndex = 0;
input.setAttribute('aria-expanded', 'true');
this.renderCasePickerList();
},
closeCasePicker() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
this._casePickerOpen = false;
this._casePickerFilter = '';
input?.setAttribute('aria-expanded', 'false');
input?.removeAttribute('aria-activedescendant');
list?.classList.add('hidden');
},
renderCasePickerList() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
const select = document.getElementById('quickStartCase');
if (!input || !list || !select) return;
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
const selectedName = select.value || 'testcase';
const maxIndex = Math.max(0, options.length - 1);
this._casePickerActiveIndex = Math.min(Math.max(this._casePickerActiveIndex || 0, 0), maxIndex);
if (options.length === 0) {
list.innerHTML = '<div class="case-combobox-empty">No cases match</div>';
list.classList.remove('hidden');
input.removeAttribute('aria-activedescendant');
return;
}
list.innerHTML = options
.map((option, index) => {
const active = index === this._casePickerActiveIndex;
const selected = option.name === selectedName;
const id = `quickStartCaseOption-${index}`;
return `
<button
type="button"
id="${id}"
class="case-combobox-option ${active ? 'active' : ''} ${selected ? 'selected' : ''}"
role="option"
aria-selected="${selected ? 'true' : 'false'}"
data-case="${escapeHtml(option.name)}"
title="${escapeHtml(option.label)}">
<span class="case-combobox-check">${selected ? '✓' : ''}</span>
<span class="case-combobox-option-label">${escapeHtml(option.label)}</span>
</button>
`;
})
.join('');
list.classList.remove('hidden');
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
},
selectQuickStartCase(caseName, { save = true } = {}) {
const select = document.getElementById('quickStartCase');
if (!select) return;
select.value = caseName || 'testcase';
this.updateCasePickerInput(select.value);
this.closeCasePicker();
this.updateDirDisplayForCase(select.value);
this.updateMobileCaseLabel(select.value);
if (save) {
this.saveLastUsedCase(select.value);
}
},
setupQuickStartCasePicker() {
const select = document.getElementById('quickStartCase');
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
const picker = document.getElementById('quickStartCasePicker');
if (!select || !input || !list || !picker || input.dataset.listenerAdded) return;
input.addEventListener('focus', () => {
input.select?.();
this.openCasePicker('');
});
input.addEventListener('click', () => {
input.select?.();
this.openCasePicker('');
});
input.addEventListener('input', () => {
this.openCasePicker(input.value);
});
input.addEventListener('keydown', event => {
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
if (event.key === 'ArrowDown') {
event.preventDefault();
this._casePickerActiveIndex = Math.min((this._casePickerActiveIndex || 0) + 1, Math.max(0, options.length - 1));
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'Enter') {
const option = options[this._casePickerActiveIndex || 0];
if (option) {
event.preventDefault();
this.selectQuickStartCase(option.name);
this.run?.();
}
} else if (event.key === 'Escape') {
event.preventDefault();
this.updateCasePickerInput(select.value);
this.closeCasePicker();
} else if (event.key === 'Tab') {
this.updateCasePickerInput(select.value);
this.closeCasePicker();
}
});
list.addEventListener('mousedown', event => event.preventDefault());
list.addEventListener('click', event => {
const option = event.target.closest?.('.case-combobox-option');
if (option?.dataset?.case) {
this.selectQuickStartCase(option.dataset.case);
}
});
if (document.addEventListener && !this._casePickerDocumentListenerAdded) {
document.addEventListener('pointerdown', event => {
if (!picker.contains(event.target)) {
this.updateCasePickerInput(select.value);
this.closeCasePicker();
}
});
this._casePickerDocumentListenerAdded = true;
}
input.dataset.listenerAdded = 'true';
},
async loadQuickStartCases(selectCaseName = null, settingsPromise = null) {
try {
// Load settings to get lastUsedCase (reuse shared promise if provided)
@@ -64,28 +261,8 @@ Object.assign(CodemanApp.prototype, {
const select = document.getElementById('quickStartCase');
// Build options - existing cases first, then testcase as fallback if not present
let options = '';
const hasTestcase = cases.some(c => c.name === 'testcase');
const isMobile = MobileDetection.getDeviceType() === 'mobile';
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
cases.forEach(c => {
const baseLabel = c.location === 'remote' && c.remote
? `${c.name} @ ${c.remote.hostId}`
: c.name;
const displayName = baseLabel.length > maxNameLength
? baseLabel.substring(0, maxNameLength) + '…'
: baseLabel;
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
});
// Add testcase option if it doesn't exist (will be created on first run)
if (!hasTestcase) {
options = `<option value="testcase">testcase</option>` + options;
}
select.innerHTML = options;
const options = this.getCasePickerOptions();
this.renderQuickStartCaseSelectOptions(select, options);
console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200));
// If a specific case was requested, select it
@@ -110,6 +287,9 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase';
this.updateMobileCaseLabel('testcase');
}
this.updateCasePickerInput(select.value);
this.renderCasePickerList();
this.closeCasePicker();
// Only add event listener once (on first load)
if (!select.dataset.listenerAdded) {
@@ -117,9 +297,11 @@ Object.assign(CodemanApp.prototype, {
this.updateDirDisplayForCase(select.value);
this.saveLastUsedCase(select.value);
this.updateMobileCaseLabel(select.value);
this.updateCasePickerInput(select.value);
});
select.dataset.listenerAdded = 'true';
}
this.setupQuickStartCasePicker();
} catch (err) {
console.error('Failed to load cases:', err);
}
@@ -511,6 +693,8 @@ Object.assign(CodemanApp.prototype, {
caseData = createCaseData.data.case;
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
@@ -558,7 +742,7 @@ Object.assign(CodemanApp.prototype, {
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell', name })
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
@@ -1656,7 +1840,14 @@ Object.assign(CodemanApp.prototype, {
// Refresh the dropdown
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
if (currentCase === name) {
// Blur the native picker before reload so it doesn't show the stale value
select.blur?.();
}
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
if (currentCase === name) {
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
}
} else {
this.showToast(data.error || 'Failed to delete case', 'error');
}
@@ -1693,11 +1884,7 @@ Object.assign(CodemanApp.prototype, {
// Build case list HTML
let html = '';
const cases = this.cases || [];
// Add testcase if not in list
const hasTestcase = cases.some(c => c.name === 'testcase');
const allCases = hasTestcase ? cases : [{ name: 'testcase' }, ...cases];
const allCases = this.getCasePickerOptions();
for (const c of allCases) {
const isSelected = c.name === currentCase;
@@ -1709,7 +1896,7 @@ Object.assign(CodemanApp.prototype, {
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-name">${escapeHtml(c.label)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
+138
View File
@@ -471,6 +471,9 @@ Object.assign(CodemanApp.prototype, {
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
});
// The Shortcuts tab renders lazily so the list reflects the CURRENT
// registry (defaults + overrides) every time it is opened.
if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.();
},
closeAppSettings() {
@@ -1464,6 +1467,9 @@ Object.assign(CodemanApp.prototype, {
// with no UI left to turn it back off. Preserve the prior stored preference.
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
// Shortcut overrides are edited from the Shortcuts tab (not rebuilt from the
// general-settings DOM), so the fresh rebuild would drop them on every save.
if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;
// Save to localStorage
this.saveAppSettingsToStorage(settings);
@@ -2387,6 +2393,138 @@ Object.assign(CodemanApp.prototype, {
}
},
// ─── Shortcut Settings (App Settings → Shortcuts tab) ────────────────────────
// Renders the list of shortcuts with capture buttons for key rebinding,
// and persists overrides under settings.shortcutOverrides (saved through
// saveAppSettingsToStorage so the device key + settings cache stay coherent).
renderShortcutSettingsList() {
const list = document.getElementById('appSettingsShortcutsList');
if (!list) return;
const registry = this.getShortcutRegistry
? this.getShortcutRegistry()
: typeof DEFAULT_SHORTCUTS !== 'undefined'
? DEFAULT_SHORTCUTS
: [];
const overrides = this.readShortcutOverridesFromSettings();
list.innerHTML = registry
.map((shortcut) => {
const bindingLabel = shortcut.displayBindings
? shortcut.displayBindings.join(' / ')
: (shortcut.bindings || []).map((b) => [...(b.modifiers || []), b.key || b.code || ''].join('+')).join(' / ');
// Only registry entries dispatched through matchesShortcutEvent() are
// configurable; fixed keys (Escape, tab arrows, …) render read-only.
const configurable = !!shortcut.action && Array.isArray(shortcut.bindings);
const overridden = !!overrides[shortcut.id];
const controls = configurable
? `<button type="button" class="shortcut-capture-btn" data-shortcut-action="capture" title="Capture new binding">Edit</button>
<button type="button" class="shortcut-reset-btn" data-shortcut-action="reset" title="Reset to default"${overridden ? '' : ' disabled'}>Reset</button>
<input class="shortcut-enabled-checkbox" type="checkbox" ${shortcut.disabled ? '' : 'checked'} data-shortcut-action="toggle" title="Enable/disable">`
: '';
return `<div class="shortcut-setting-row${configurable ? '' : ' shortcut-setting-row--fixed'}" data-shortcut-id="${escapeHtml(shortcut.id)}">
<label class="shortcut-setting-label">${escapeHtml(shortcut.label)}</label>
<input class="shortcut-binding-input" type="text" readonly value="${escapeHtml(bindingLabel)}" placeholder="(none)" data-id="${escapeHtml(shortcut.id)}">
${controls}
</div>`;
})
.join('');
this._wireShortcutSettingsList(list);
},
// Delegated handlers (no inline onclick — registry ids never land inside a
// JS string context, and the listeners survive re-renders).
_wireShortcutSettingsList(list) {
if (list.dataset.shortcutListenersAdded) return;
list.dataset.shortcutListenersAdded = 'true';
list.addEventListener('click', (e) => {
const btn = e.target?.closest?.('[data-shortcut-action]');
if (!btn) return;
const id = btn.closest?.('[data-shortcut-id]')?.dataset?.shortcutId;
if (!id) return;
if (btn.dataset.shortcutAction === 'capture') this.startShortcutCapture(id);
else if (btn.dataset.shortcutAction === 'reset') this.resetShortcutOverride(id);
});
list.addEventListener('change', (e) => {
const box = e.target;
if (!box?.matches?.('[data-shortcut-action="toggle"]')) return;
const id = box.closest?.('[data-shortcut-id]')?.dataset?.shortcutId;
if (id) this.toggleShortcutEnabled(id, box.checked);
});
},
readShortcutOverridesFromSettings() {
const settings = this.loadAppSettingsFromStorage();
return settings.shortcutOverrides || {};
},
startShortcutCapture(shortcutId) {
const input = document.querySelector(`.shortcut-binding-input[data-id="${shortcutId}"]`);
if (!input) return;
input.value = 'Press keys…';
input.focus();
this._capturingShortcutId = shortcutId;
// Persistent listener (NOT {once}) — the first keydown of a combo like
// Ctrl+Shift+P is the modifier itself ('Control'), which must not end the
// capture. The first non-modifier key completes it.
const onCaptureKeydown = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') return;
input.removeEventListener('keydown', onCaptureKeydown);
this.onShortcutCaptureKeydown(e, shortcutId);
};
input.addEventListener('keydown', onCaptureKeydown);
},
onShortcutCaptureKeydown(e, shortcutId) {
e.preventDefault();
e.stopPropagation();
this._capturingShortcutId = null;
if (e.key === 'Escape') {
this.renderShortcutSettingsList();
return;
}
// Require a real chord: the dispatcher has no focus-target guard, so a
// bare-key binding would fire while typing in any input.
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
this.renderShortcutSettingsList();
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
return;
}
const modifiers = [];
if (e.ctrlKey) modifiers.push('ctrl');
if (e.metaKey) modifiers.push('meta');
if (e.shiftKey) modifiers.push('shift');
if (e.altKey) modifiers.push('alt');
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = {
...(shortcutOverrides[shortcutId] || {}),
bindings: [{ modifiers, key: e.key, code: e.code }],
};
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
resetShortcutOverride(shortcutId) {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
delete shortcutOverrides[shortcutId];
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
toggleShortcutEnabled(shortcutId, enabled) {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
closeAllPanels() {
this.closeSessionOptions();
this.closeAppSettings();
+403 -1
View File
@@ -3034,7 +3034,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
/* backdrop-filter creates a stacking context, trapping the popover's
z-index inside the toolbar. When the popover is open, raise the toolbar
above the CJK input (z-index 52) so the popover is interactable. */
.toolbar:has(.case-settings-popover:not(.hidden)) {
.toolbar:has(.case-settings-popover:not(.hidden)),
.toolbar:has(.case-combobox-list:not(.hidden)) {
z-index: 100;
}
@@ -3668,6 +3669,103 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
position: relative;
}
.case-combobox {
position: relative;
width: clamp(170px, 18vw, 280px);
}
.case-combobox-input {
width: 100%;
height: 100%;
min-height: 28px;
padding: 0.4rem 1.6rem 0.4rem 0.65rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: var(--btn-radius) 0 0 var(--btn-radius);
color: var(--text-dim);
font-size: 0.75rem;
font-family: inherit;
outline: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8b97' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
transition: border-color var(--transition-smooth), background var(--transition-smooth), box-shadow var(--transition-smooth);
}
.case-combobox-input:hover,
.case-combobox-input:focus {
background-color: rgba(255, 255, 255, 0.07);
border-color: var(--accent);
color: var(--text);
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
}
.case-combobox-list {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
width: min(340px, calc(100vw - 24px));
max-height: min(320px, 55vh);
overflow-y: auto;
padding: 0.35rem;
background: rgba(22, 27, 35, 0.98);
border: 1px solid rgba(120, 141, 170, 0.28);
border-radius: 8px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
z-index: 1000;
}
.case-combobox-list.hidden {
display: none;
}
.case-combobox-option {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
width: 100%;
min-height: 34px;
padding: 0.35rem 0.45rem;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-dim);
font: inherit;
font-size: 0.78rem;
text-align: left;
cursor: pointer;
}
.case-combobox-option:hover,
.case-combobox-option.active {
background: rgba(59, 130, 246, 0.16);
border-color: rgba(96, 165, 250, 0.28);
color: var(--text);
}
.case-combobox-option.selected {
color: var(--text);
}
.case-combobox-check {
color: var(--green);
font-weight: 700;
}
.case-combobox-option-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-combobox-empty {
padding: 0.85rem 0.75rem;
color: var(--text-muted);
font-size: 0.78rem;
text-align: center;
}
.toolbar-select {
padding: 0.4rem 1.5rem 0.4rem 0.6rem;
background: rgba(255, 255, 255, 0.05);
@@ -3686,6 +3784,16 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transition: all var(--transition-smooth);
}
.toolbar-select.case-native-select {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
opacity: 0;
pointer-events: none;
}
.toolbar-select:hover {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.07);
@@ -5417,6 +5525,300 @@ kbd {
padding: 1rem;
}
/* Command palette (COD-153) */
.command-palette-modal {
align-items: flex-start;
padding-top: min(16vh, 120px);
}
.command-palette-shell {
position: relative;
width: min(92vw, 620px);
max-height: min(70vh, 620px);
display: flex;
flex-direction: column;
overflow: hidden;
background: rgba(19, 19, 22, 0.97);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.55), 0 4px 18px rgba(0, 0, 0, 0.35);
}
.command-palette-input-row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 0.55rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.command-palette-search-icon {
color: var(--text-muted);
font-size: 1rem;
text-align: center;
}
.command-palette-search {
min-width: 0;
width: 100%;
background: transparent;
border: 0;
color: var(--text);
font: inherit;
font-size: 0.95rem;
outline: 0;
}
.command-palette-search:focus-visible {
box-shadow: none;
}
.command-palette-search::placeholder {
color: var(--text-muted);
}
.command-palette-input-row kbd {
padding: 0.15rem 0.35rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 0.68rem;
}
.command-palette-label {
padding: 0.55rem 0.8rem 0.25rem;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0;
text-transform: uppercase;
}
.command-palette-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-height: 0;
overflow-y: auto;
padding: 0.25rem 0.45rem 0.55rem;
}
.command-palette-item {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 0.55rem;
width: 100%;
min-height: 52px;
padding: 0.45rem 0.55rem;
color: var(--text);
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
text-align: left;
cursor: pointer;
}
.command-palette-item:hover,
.command-palette-item.active {
background: rgba(59, 130, 246, 0.14);
border-color: rgba(59, 130, 246, 0.28);
}
.command-palette-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
font-size: 0.9rem;
}
.command-palette-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.command-palette-title,
.command-palette-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-title {
font-size: 0.85rem;
font-weight: 600;
}
.command-palette-subtitle {
color: var(--text-muted);
font-size: 0.72rem;
}
/* COD-192: "Browse all sessions…" escape hatch — visually secondary */
.command-palette-item--browse {
margin-top: 4px;
}
.command-palette-item--browse .command-palette-icon {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
}
.command-palette-item--browse .command-palette-title {
color: var(--text-muted);
font-weight: 500;
}
/* Session Manager modal (COD-121) */
.session-manager-modal {
max-width: 720px;
width: min(92vw, 720px);
max-height: 70vh;
display: flex;
flex-direction: column;
}
.session-manager-modal .modal-body {
display: flex;
flex-direction: column;
gap: 0.6rem;
overflow: hidden;
padding: 1rem;
}
#sessionManagerSearch {
flex: 0 0 auto;
}
.session-manager-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
overflow-y: auto;
}
/* Shortcut settings rows (App Settings → Shortcuts, COD-157) */
.shortcut-setting-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 190px) auto auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.shortcut-setting-row:last-child {
border-bottom: none;
}
.shortcut-setting-label {
color: var(--text);
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shortcut-binding-input {
min-width: 0;
padding: 0.3rem 0.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--btn-radius);
color: var(--text-dim);
font-family: monospace;
font-size: 0.72rem;
cursor: default;
}
.shortcut-binding-input:focus {
border-color: var(--accent);
color: var(--text);
outline: none;
}
.shortcut-capture-btn,
.shortcut-reset-btn {
padding: 0.3rem 0.6rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--btn-radius);
color: var(--text-dim);
font-size: 0.72rem;
cursor: pointer;
transition: all var(--transition-smooth);
}
.shortcut-capture-btn:hover,
.shortcut-reset-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.09);
color: var(--text);
}
.shortcut-reset-btn:disabled {
opacity: 0.4;
cursor: default;
}
.shortcut-enabled-checkbox {
accent-color: var(--accent);
cursor: pointer;
}
/* Shortcut overlay modal (registry-driven; opened with Ctrl+? / Alt+?) */
.shortcut-overlay-modal .modal-content {
max-width: 560px;
}
.shortcut-overlay-group {
margin-bottom: 1rem;
}
.shortcut-overlay-group:last-child {
margin-bottom: 0;
}
.shortcut-overlay-group-label {
margin-bottom: 0.35rem;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.shortcut-overlay-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.3rem 0;
}
.shortcut-overlay-label {
color: var(--text);
font-size: 0.8rem;
}
.shortcut-overlay-keys {
flex-shrink: 0;
color: var(--text-dim);
}
.shortcut-overlay-footer {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
text-align: right;
}
.away-digest-ranges {
display: flex;
flex-wrap: wrap;
+26 -5
View File
@@ -120,12 +120,28 @@ Object.assign(CodemanApp.prototype, {
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
// the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“", Option+K -> "˚") are suppressed here too and
// don't leak an escape sequence into the focused terminal on every tab switch.
if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
if (
ev.altKey &&
!ev.ctrlKey &&
!ev.shiftKey &&
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
) {
return false;
}
// Command palette chord (COD-153): keep it out of the PTY. The document
// CAPTURE handler has already opened the palette by the time xterm sees
// this keydown, but its preventDefault() does NOT stop xterm — without
// this gate Ctrl+K would ALSO write 0x0b (readline kill-line) into the
// live session behind the palette, truncating whatever the user had
// typed. Route through the registry-aware checker so a rebound or
// disabled palette shortcut restores normal terminal Ctrl+K.
if (ev.type === 'keydown' && this.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
@@ -1165,6 +1181,7 @@ Object.assign(CodemanApp.prototype, {
* @param {Array} cases linked cases (for #caseName label)
* @param {object} [options]
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
* @param {Function} [options.onActivate] main-row click handler override (default: resume the conversation)
*/
_buildHistoryItem(s, cases, options) {
const showViewAll = options?.showViewAll !== false;
@@ -1186,10 +1203,14 @@ Object.assign(CodemanApp.prototype, {
item.className = 'history-item';
item.title = s.workingDir;
// Main row: clickable surface that triggers resume
// Main row: clickable surface that triggers resume (or a caller-supplied
// activation — the Session Manager switches to live sessions instead)
const mainRow = document.createElement('div');
mainRow.className = 'history-item-main';
mainRow.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
mainRow.addEventListener(
'click',
options?.onActivate || (() => this.resumeHistorySession(s.sessionId, s.workingDir))
);
const textCol = document.createElement('div');
textCol.className = 'history-item-text';
+454
View File
@@ -0,0 +1,454 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadPaletteHarness(overrides: Record<string, any> = {}) {
const elements: Record<string, any> = {};
const listeners: Record<string, (event: any) => void> = {};
const CodemanApp = function CodemanApp(this: any) {};
const makeClassList = () => {
const classes = new Set<string>();
return {
add: (...names: string[]) => names.forEach((name) => classes.add(name)),
remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
contains: (name: string) => classes.has(name),
toggle: (name: string, force?: boolean) => {
const shouldAdd = force ?? !classes.has(name);
if (shouldAdd) classes.add(name);
else classes.delete(name);
return shouldAdd;
},
};
};
elements.commandPaletteModal = {
classList: makeClassList(),
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
listeners[`modal:${event}`] = handler;
}),
};
elements.commandPaletteSearch = {
value: '',
focus: vi.fn(),
select: vi.fn(),
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
listeners[`search:${event}`] = handler;
}),
};
elements.commandPaletteList = {
innerHTML: '',
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
listeners[`list:${event}`] = handler;
}),
};
elements.quickStartCase = {
value: 'plex-previews',
};
const context = vm.createContext({
CodemanApp,
document: {
getElementById: (id: string) => elements[id] ?? null,
createElement: (tagName: string) => ({
tagName: tagName.toUpperCase(),
value: '',
textContent: '',
}),
},
console,
escapeHtml: (value: string) =>
String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'),
...overrides,
});
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
const app = new (CodemanApp as any)();
app.sessions = new Map([
[
'sess-alpha',
{
id: 'sess-alpha',
name: 'Alpha API cleanup',
workingDir: '/repo/api',
mode: 'codex',
status: 'busy',
},
],
[
'sess-beta',
{
id: 'sess-beta',
name: 'Billing prompt polish',
workingDir: '/repo/billing',
mode: 'claude',
status: 'idle',
},
],
[
'sess-gamma',
{
id: 'sess-gamma',
workingDir: '/repo/flux-player',
mode: 'codex',
status: 'busy',
},
],
]);
app.sessionOrder = ['sess-beta', 'sess-alpha', 'sess-gamma'];
app.cases = [{ name: 'plex-previews' }, { name: 'flux-player' }, { name: 'api-tools' }];
app.selectSession = vi.fn();
app.run = vi.fn();
app.getShortId = (id: string) => id.slice(0, 8);
app.getSessionName = (session: any) =>
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
return { app, elements, listeners };
}
describe('Command-K session palette', () => {
it('recognizes Cmd/Ctrl-K outside text-entry contexts only', () => {
const { app } = loadPaletteHarness();
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', metaKey: true, ctrlKey: false, target: null })).toBe(
true
);
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'K', metaKey: false, ctrlKey: true, target: null })).toBe(
true
);
expect(
app.shouldOpenCommandPaletteFromShortcut({
key: 'k',
metaKey: true,
ctrlKey: false,
target: { tagName: 'INPUT', isContentEditable: false },
})
).toBe(false);
expect(
app.shouldOpenCommandPaletteFromShortcut({
key: 'k',
metaKey: false,
ctrlKey: true,
target: { tagName: 'DIV', isContentEditable: true },
})
).toBe(false);
});
it('recognizes Ctrl-K from the focused xterm helper textarea', () => {
const { app } = loadPaletteHarness();
expect(
app.shouldOpenCommandPaletteFromShortcut({
key: 'k',
code: 'KeyK',
metaKey: false,
ctrlKey: true,
altKey: false,
target: {
tagName: 'TEXTAREA',
isContentEditable: false,
classList: { contains: (name: string) => name === 'xterm-helper-textarea' },
},
})
).toBe(true);
});
it('recognizes macOS Option-K by physical key code', () => {
const { app } = loadPaletteHarness();
expect(
app.shouldOpenCommandPaletteFromShortcut({
key: '˚',
code: 'KeyK',
metaKey: false,
ctrlKey: false,
altKey: true,
target: null,
})
).toBe(true);
});
it('rejects the palette chord when extra modifiers are held (Ctrl+Shift+K is the Firefox devtools console)', () => {
const { app } = loadPaletteHarness();
expect(
app.shouldOpenCommandPaletteFromShortcut({ key: 'K', code: 'KeyK', ctrlKey: true, shiftKey: true, target: null })
).toBe(false);
});
it('honors a disabled or rebound palette shortcut from the registry', () => {
const { app } = loadPaletteHarness();
// Disabled entry → never opens, even for the default chord.
app.getShortcutRegistry = () => [
{ id: 'command-palette', disabled: true, bindings: [{ modifiers: ['ctrl'], key: 'k', code: 'KeyK' }] },
];
app.matchesShortcutEvent = () => true;
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', code: 'KeyK', ctrlKey: true, target: null })).toBe(
false
);
// Rebound entry → the new chord opens, the old default no longer does.
app.getShortcutRegistry = () => [{ id: 'command-palette', bindings: [{ modifiers: ['ctrl'], code: 'KeyP' }] }];
app.matchesShortcutEvent = (e: any, s: any) => e.code === s.bindings[0].code;
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', code: 'KeyK', ctrlKey: true, target: null })).toBe(
false
);
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'p', code: 'KeyP', ctrlKey: true, target: null })).toBe(
true
);
});
it('opens and focuses the palette search box', () => {
const { app, elements } = loadPaletteHarness();
app.openCommandPalette();
expect(elements.commandPaletteModal.classList.contains('active')).toBe(true);
expect(elements.commandPaletteSearch.focus).toHaveBeenCalledTimes(1);
expect(elements.commandPaletteList.innerHTML).toContain('Alpha API cleanup');
});
it('filters currently open sessions and always includes a new-session action', () => {
const { app } = loadPaletteHarness();
const results = app.buildCommandPaletteItems('bill');
expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session', 'browse-sessions']);
expect(results[0]).toMatchObject({ type: 'session', sessionId: 'sess-beta', title: 'Billing prompt polish' });
expect(results[1]).toMatchObject({ type: 'new-session', title: 'New session' });
expect(results[2]).toMatchObject({ type: 'browse-sessions' });
});
it('uses the tab name instead of the short session id for unnamed sessions', () => {
const { app } = loadPaletteHarness();
const results = app.buildCommandPaletteItems('flux-player');
expect(results[0]).toMatchObject({
type: 'session',
sessionId: 'sess-gamma',
title: 'flux-player',
});
expect(results[0].title).not.toBe('sess-gam');
});
it('uses the best matching case for the new-session action', async () => {
const { app, elements } = loadPaletteHarness();
const results = app.buildCommandPaletteItems('flux');
const newSession = results.find((item: any) => item.type === 'new-session');
expect(newSession).toMatchObject({
type: 'new-session',
caseName: 'flux-player',
subtitle: 'Run Claude in flux-player',
});
app.commandPaletteItems = [newSession];
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
expect(elements.quickStartCase.value).toBe('flux-player');
expect(app.run).toHaveBeenCalledTimes(1);
});
it('adds the matched case option before selecting it for a new session', async () => {
const { app, elements } = loadPaletteHarness();
const options = [{ value: 'plex-previews' }];
elements.quickStartCase = {
tagName: 'SELECT',
options,
appendChild: vi.fn((option: any) => options.push(option)),
get value() {
return this._value || '';
},
set value(next: string) {
this._value = options.some((option) => option.value === next) ? next : '';
},
};
elements.quickStartCase.value = 'plex-previews';
const newSession = app.buildCommandPaletteItems('flux').find((item: any) => item.type === 'new-session');
app.commandPaletteItems = [newSession];
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
expect(elements.quickStartCase.appendChild).toHaveBeenCalledTimes(1);
expect(elements.quickStartCase.value).toBe('flux-player');
expect(app.run).toHaveBeenCalledTimes(1);
});
it('routes the new-session case pick through selectQuickStartCase when the picker mixin is loaded', async () => {
const { app } = loadPaletteHarness();
app.selectQuickStartCase = vi.fn();
const newSession = app.buildCommandPaletteItems('flux').find((item: any) => item.type === 'new-session');
app.commandPaletteItems = [newSession];
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
// Keeps the searchable combobox, dir display, and lastUsedCase in sync
// instead of silently mutating the hidden native <select>.
expect(app.selectQuickStartCase).toHaveBeenCalledWith('flux-player');
expect(app.run).toHaveBeenCalledTimes(1);
});
it('activates the highlighted session result', async () => {
const { app } = loadPaletteHarness();
app.openCommandPalette();
app.commandPaletteItems = app.buildCommandPaletteItems('api');
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
expect(app.run).not.toHaveBeenCalled();
});
it('activates the new-session result through the current run path', async () => {
const { app } = loadPaletteHarness();
app.openCommandPalette();
app.commandPaletteItems = app.buildCommandPaletteItems('does-not-match');
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
expect(app.run).toHaveBeenCalledTimes(1);
expect(app.selectSession).not.toHaveBeenCalled();
});
it('routes Enter from the palette search to the current result', async () => {
const { app, listeners } = loadPaletteHarness();
app.openCommandPalette();
app.commandPaletteItems = app.buildCommandPaletteItems('api');
app.commandPaletteActiveIndex = 0;
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() };
await listeners['search:keydown'](event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
});
});
describe('Session Manager unified list', () => {
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
const { app, elements } = loadPaletteHarness({
fetch: async (url: string) => {
expect(url).toBe('/api/sessions/unified?limit=200&q=api');
return {
ok: true,
status: 200,
json: async () => ({
success: true,
data: {
sessions: [
{
sessionId: 'sess-alpha',
name: 'Alpha API cleanup',
workingDir: '/repo/api',
lastActivityAt: 1751000000000,
sources: ['live'],
},
{
sessionId: 'conv-uuid-1',
workingDir: '/repo/old',
sizeBytes: 2048,
firstPrompt: 'old prompt',
lastActivityAt: 1750000000000,
sources: ['history'],
},
],
total: 2,
},
}),
};
},
});
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
app._buildHistoryItem = vi.fn(() => ({}));
app.resumeHistorySession = vi.fn();
await app._loadSessionManagerList('api');
expect(app._buildHistoryItem).toHaveBeenCalledTimes(2);
const [liveRecord, , liveOptions] = app._buildHistoryItem.mock.calls[0];
expect(liveRecord).toMatchObject({
sessionId: 'sess-alpha',
workingDir: '/repo/api',
sizeBytes: 0,
firstPrompt: 'Alpha API cleanup',
});
expect(new Date(liveRecord.lastModified).getTime()).toBe(1751000000000);
expect(liveOptions.showViewAll).toBe(false);
// Live row → switch to the session (resuming it would spawn a duplicate).
liveOptions.onActivate();
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
expect(app.resumeHistorySession).not.toHaveBeenCalled();
// History row → resume by conversation UUID.
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
historyOptions.onActivate();
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old');
});
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
const appended: any[] = [];
const { app, elements } = loadPaletteHarness({
fetch: async () => ({
ok: false,
status: 503,
json: async () => ({ success: false, error: 'unified list unavailable', errorCode: 'OPERATION_FAILED' }),
}),
});
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: (el: any) => appended.push(el) };
await app._loadSessionManagerList('');
expect(appended).toHaveLength(1);
expect(appended[0].textContent).toBe('unified list unavailable');
expect(appended[0].textContent).not.toBe('No sessions found');
});
});
describe('panel close helpers', () => {
it('closes panels when the mobile header helper is unavailable', () => {
const CodemanApp = function CodemanApp(this: any) {};
const elements: Record<string, any> = {
monitorPanel: { classList: { remove: vi.fn() } },
subagentsPanel: { classList: { remove: vi.fn() } },
};
const context = vm.createContext({
CodemanApp,
document: {
getElementById: (id: string) => elements[id] ?? null,
},
console,
});
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
const app = new (CodemanApp as any)();
app.closeSessionOptions = vi.fn();
app.closeAppSettings = vi.fn();
app.cancelCloseSession = vi.fn();
app.closeTokenStats = vi.fn();
expect(() => app.closeAllPanels()).not.toThrow();
expect(elements.monitorPanel.classList.remove).toHaveBeenCalledWith('open');
expect(elements.subagentsPanel.classList.remove).toHaveBeenCalledWith('open');
});
});
+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const INDEX_HTML = readFileSync(join(process.cwd(), 'src/web/public/index.html'), 'utf-8');
function normalizedHtml(value: string): string {
return value.replace(/\s+/g, ' ');
}
function extractElementById(html: string, id: string): string {
const idIndex = html.indexOf(`id="${id}"`);
expect(idIndex, `expected #${id} to exist`).toBeGreaterThanOrEqual(0);
const start = html.lastIndexOf('<', idIndex);
expect(start, `expected #${id} start tag`).toBeGreaterThanOrEqual(0);
const nextSection = html.indexOf('<!-- Monitor Panel', idIndex);
expect(nextSection, `expected section marker after #${id}`).toBeGreaterThanOrEqual(0);
const end = nextSection;
return html.slice(start, end);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function expectShortcut(html: string, keys: string[], label: string): void {
const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*');
expect(html).toMatch(new RegExp(`${keyPattern}.*?${label}`, 'i'));
}
describe('help modal shortcuts', () => {
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
it('documents implemented global and tab shortcuts', () => {
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session');
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
expectShortcut(helpModal, ['Alt/Option', '1-9'], 'Switch to Tab N');
expectShortcut(helpModal, ['Ctrl', '{'], 'Move Active Tab Left');
expectShortcut(helpModal, ['Ctrl', '}'], 'Move Active Tab Right');
expectShortcut(helpModal, ['Ctrl', '?'], 'Show Shortcuts');
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>\/<\/kbd>.*?Show Shortcuts/i);
expectShortcut(helpModal, ['Ctrl', 'Shift', 'V'], 'Voice Input');
expectShortcut(helpModal, ['Escape'], 'Close Panels');
});
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
expectShortcut(helpModal, ['Ctrl', 'L'], 'Clear Terminal');
expectShortcut(helpModal, ['Ctrl', '+'], 'Increase Font');
expectShortcut(helpModal, ['Ctrl', '-'], 'Decrease Font');
expectShortcut(helpModal, ['Shift', 'Enter'], 'Insert Newline');
expectShortcut(helpModal, ['Ctrl', 'Enter'], 'Insert Newline');
// Ctrl+Shift+R (restore terminal size) is still dispatched — keep it documented.
expectShortcut(helpModal, ['Ctrl', 'Shift', 'R'], 'Restore Terminal Size');
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>K<\/kbd>/i);
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>Enter<\/kbd>.*?(Run|Start)/i);
});
it('does not advertise the removed Ctrl+Enter run binding in launch UI hints', () => {
expect(INDEX_HTML).not.toContain('Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start');
expect(INDEX_HTML).not.toContain('title="Run (Ctrl+Enter)"');
});
});
+25 -1
View File
@@ -24,7 +24,7 @@ describe('keyboard shortcuts', () => {
// terminal-ui.js must gate its xterm pass-through on the SAME physical e.code set the
// app.js handler consumes; otherwise Alt+[ / Alt+] (and Option+digit on remapped macOS
// layouts) switch tabs AND inject ESC<char> into the focused terminal. Keep in sync.
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code');
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code');
});
it('documents the Alt/Option shortcuts in help and README', () => {
@@ -34,4 +34,28 @@ describe('keyboard shortcuts', () => {
expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
});
it('documents the Command-K open-session palette in help and README', () => {
expect(appSource).toContain('this.openCommandPalette()');
expect(helpHtml).toContain('<kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd>');
expect(readme).toMatch(/\| `Ctrl\/Cmd\/Option\+K`\s+\| Find open session or start a new one\s+\|/);
});
it('gates the palette chord in the xterm custom key handler (no 0x0b kill-line into the PTY)', () => {
// The document-level capture handler opens the palette, but preventDefault()
// does NOT stop xterm from evaluating Ctrl+K into 0x0b and writing it to the
// live PTY — terminal-ui.js must return false for the palette chord.
expect(terminalUiSource).toMatch(/ev\.type === 'keydown' && this\.shouldOpenCommandPaletteFromShortcut\?\.\(ev\)/);
});
it('dispatches document shortcuts through the shortcut registry (rebind/disable aware)', () => {
// The legacy hardcoded SHORTCUTS table must stay gone — dispatch goes through
// getShortcutRegistry() + matchesShortcutEvent() so overrides and per-shortcut
// disables (App Settings → Shortcuts) actually take effect.
expect(appSource).not.toContain('const SHORTCUTS = [');
expect(appSource).toContain('const SHORTCUT_ACTIONS = {');
expect(appSource).toContain('for (const shortcut of this.getShortcutRegistry())');
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
});
});
+227
View File
@@ -144,6 +144,233 @@ describe('Codex quick start settings', () => {
});
});
describe('case selector refresh', () => {
it('sorts case picker options alphabetically and filters by case or host label', () => {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
const cases = [
{ name: 'zeta' },
{ name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/saqeb/moneytrove' } },
{ name: 'Alpha' },
{ name: 'plex-previews' },
];
const options = app.buildCasePickerOptions(cases);
expect(options.map((option: any) => option.name)).toEqual([
'Alpha',
'moneytrove',
'plex-previews',
'testcase',
'zeta',
]);
expect(options.find((option: any) => option.name === 'moneytrove')?.label).toBe('moneytrove @ mac-mini');
expect(app.filterCasePickerOptions(options, 'MAC').map((option: any) => option.name)).toEqual(['moneytrove']);
expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']);
});
it('launches the highlighted case with the current run mode when pressing Enter in the picker', () => {
const elements: Record<string, any> = {};
const listeners: Record<string, (event: any) => void> = {};
const CodemanApp = function CodemanApp(this: any) {};
elements.quickStartCase = {
value: 'Alpha',
dataset: {},
};
elements.quickStartCaseSearch = {
value: 'mon',
dataset: {},
setAttribute: vi.fn(),
removeAttribute: vi.fn(),
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
listeners[event] = handler;
}),
select: vi.fn(),
};
elements.quickStartCaseList = {
innerHTML: '',
classList: { add: vi.fn(), remove: vi.fn() },
addEventListener: vi.fn(),
};
elements.quickStartCasePicker = {
contains: () => true,
};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: (id: string) => elements[id] ?? null,
addEventListener: vi.fn(),
},
console,
escapeHtml: (s: string) => s,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.cases = [
{ name: 'Alpha' },
{ name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/saqeb/moneytrove' } },
{ name: 'zeta' },
];
app.updateDirDisplayForCase = vi.fn();
app.updateMobileCaseLabel = vi.fn();
app.saveLastUsedCase = vi.fn();
app.run = vi.fn(async () => {});
app.setupQuickStartCasePicker();
listeners.keydown({ key: 'Enter', preventDefault: vi.fn() });
expect(elements.quickStartCase.value).toBe('moneytrove');
expect(app.run).toHaveBeenCalledTimes(1);
});
it('creates remote shell sessions by caseName instead of remote display path', async () => {
const elements: Record<string, any> = {
quickStartCase: { value: 'gpu-work' },
shellCount: { value: '1' },
};
const requests: Array<{ url: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: {
getItem: () => null,
setItem: () => {},
},
document: {
getElementById: (id: string) => elements[id] ?? null,
},
fetch: async (url: string, init?: { body?: string }) => {
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
if (url === '/api/cases/gpu-work') {
return {
json: async () => ({
success: true,
data: {
name: 'gpu-work',
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
location: 'remote',
remote: { hostId: 'gpu-box', path: '/home/ubuntu/work' },
},
}),
};
}
if (url === '/api/sessions') {
return { json: async () => ({ success: true, data: { session: { id: 'sess-1' } } }) };
}
if (url === '/api/sessions/sess-1/shell') return { json: async () => ({ success: true }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
app.sessions = new Map();
app.cases = [{ name: 'gpu-work', path: 'ubuntu@10.0.0.42:/home/ubuntu/work', location: 'remote' }];
app.getTerminalDimensions = () => null;
app.selectSession = async () => {};
await app.runShell();
expect(requests.find((req) => req.url === '/api/sessions')?.body).toMatchObject({
caseName: 'gpu-work',
mode: 'shell',
});
expect(requests.find((req) => req.url === '/api/sessions')?.body).not.toHaveProperty('workingDir');
});
it('removes a deleted selected case from the dropdown and blurs the native picker', async () => {
const elements: Record<string, any> = {};
const requests: Array<{ url: string; method: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const quickStartCase = {
value: 'deleted-case',
innerHTML: '<option value="deleted-case">deleted-case</option><option value="kept-case">kept-case</option>',
dataset: {},
blur: vi.fn(),
addEventListener: vi.fn(),
};
elements.quickStartCase = quickStartCase;
elements.caseManageList = { innerHTML: '' };
elements.mobileCaseName = { textContent: '' };
elements.dirDisplay = { textContent: '' };
elements.dirInput = { value: '' };
const context = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop' },
localStorage: {
getItem: () => null,
setItem: () => {},
},
document: {
getElementById: (id: string) => elements[id] ?? null,
},
confirm: () => true,
fetch: async (url: string, init?: { method?: string; body?: string }) => {
requests.push({ url, method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
if (url === '/api/cases/deleted-case')
return { json: async () => ({ success: true, data: { name: 'deleted-case' } }) };
// The server's preSerialization hook wraps bare payloads as { success, data },
// so the frontend reads `.data` off every JSON response — mirror that here.
if (url === '/api/settings')
return { ok: true, json: async () => ({ success: true, data: { lastUsedCase: 'deleted-case' } }) };
if (url === '/api/cases')
return { json: async () => ({ success: true, data: [{ name: 'kept-case', path: '/tmp/kept-case' }] }) };
if (url === '/api/cases/kept-case')
return { json: async () => ({ success: true, data: { path: '/tmp/kept-case' } }) };
if (url === '/api/settings' && init?.method === 'PUT') return { json: async () => ({ success: true }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
escapeHtml: (s: string) => s,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.cases = [
{ name: 'deleted-case', path: '/tmp/deleted-case' },
{ name: 'kept-case', path: '/tmp/kept-case' },
];
app.showToast = vi.fn();
await app.deleteCase('deleted-case');
expect(quickStartCase.blur).toHaveBeenCalled();
expect(quickStartCase.innerHTML).not.toContain('deleted-case');
expect(quickStartCase.innerHTML).toContain('kept-case');
expect(elements.mobileCaseName.textContent).toBe('kept-case');
expect(requests).toContainEqual({
url: '/api/settings',
method: 'PUT',
body: { lastUsedCase: 'kept-case' },
});
});
});
describe('Gemini quick start', () => {
// Regression guard for the ApiResponse-envelope unwrap in runGemini(): the
// status check must read `.data.available` and the quick-start response must
+232
View File
@@ -0,0 +1,232 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const appSource = readFileSync('src/web/public/app.js', 'utf8');
const settingsSource = readFileSync('src/web/public/settings-ui.js', 'utf8');
const htmlSource = readFileSync('src/web/public/index.html', 'utf8');
describe('shortcut registry and overlay', () => {
it('defines shortcut metadata that can be overridden from global settings', () => {
expect(appSource).toContain('const DEFAULT_SHORTCUTS = [');
expect(appSource).toContain('shortcutOverrides');
expect(appSource).toContain('getShortcutRegistry()');
expect(appSource).toContain('matchesShortcutEvent(e, shortcut)');
});
it('renders a shortcut overlay modal from the registry', () => {
expect(htmlSource).toContain('id="shortcutOverlayModal"');
expect(htmlSource).toContain('id="shortcutOverlayList"');
expect(appSource).toContain('showShortcutOverlay()');
expect(appSource).toContain('renderShortcutOverlay()');
expect(appSource).toContain('closeShortcutOverlay()');
});
it('adds Ctrl/Option question-mark bindings for the overlay', () => {
expect(appSource).toContain("id: 'show-shortcuts'");
expect(appSource).toContain("modifiers: ['ctrl']");
expect(appSource).toContain("modifiers: ['alt']");
expect(appSource).toContain("key: '?'");
expect(appSource).toContain("code: 'Slash'");
expect(appSource).not.toContain("key: '/'");
});
it('exposes shortcut overrides in a dedicated App Settings shortcuts tab', () => {
expect(htmlSource).toContain('data-tab="settings-shortcuts"');
expect(htmlSource).toContain('id="settings-shortcuts"');
expect(htmlSource).toContain('id="appSettingsShortcutsList"');
expect(htmlSource).not.toContain('id="appSettingsShortcutOverrides"');
expect(htmlSource).not.toContain('Shortcut Overrides</span>');
expect(settingsSource).toContain('renderShortcutSettingsList');
expect(settingsSource).toContain('readShortcutOverridesFromSettings');
expect(settingsSource).toContain('startShortcutCapture');
expect(settingsSource).toContain('onShortcutCaptureKeydown');
expect(settingsSource).toContain('settings.shortcutOverrides');
});
it('renders shortcut rows with capture, typed input, reset, and disable controls', () => {
expect(settingsSource).toContain('shortcut-setting-row');
expect(settingsSource).toContain('shortcut-capture-btn');
expect(settingsSource).toContain('shortcut-binding-input');
expect(settingsSource).toContain('shortcut-reset-btn');
expect(settingsSource).toContain('shortcut-enabled-checkbox');
});
it('styles the shortcut settings rows and overlay (no unstyled tab)', () => {
const css = readFileSync('src/web/public/styles.css', 'utf8');
expect(css).toContain('.shortcut-setting-row {');
expect(css).toContain('.shortcut-capture-btn,');
expect(css).toContain('.shortcut-overlay-row {');
});
it('saveAppSettings preserves shortcutOverrides (rebuilt-from-DOM saves must not wipe them)', () => {
// Same trap as showTokenCount/showCost: saveAppSettings() rebuilds the settings
// object fresh from the DOM, so keys edited elsewhere (the Shortcuts tab) must be
// explicitly carried over from the previously stored blob.
expect(settingsSource).toContain(
'if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;'
);
});
it('keeps the full help modal reachable now that Ctrl+? opens the registry overlay', () => {
// The legacy #helpModal (full shortcut reference) lost its only opener when
// Ctrl+? was rerouted to the overlay; the overlay footer must link to it.
expect(htmlSource).toContain('shortcut-overlay-footer');
expect(htmlSource).toContain('app.closeShortcutOverlay(); app.showHelp()');
});
});
// ─── Functional coverage (vm-sandbox harness, mirrors run-mode-ui.test.ts) ────
// The grep assertions above pin the wiring; these exercise the actual
// persistence round-trip and capture flow that were broken in review.
function makeLocalStorage() {
const store = new Map<string, string>();
return {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
key: (i: number) => [...store.keys()][i] ?? null,
get length() {
return store.size;
},
};
}
function loadSettingsHarness() {
const CodemanApp = function CodemanApp(this: any) {};
const localStorage = makeLocalStorage();
const elements: Record<string, any> = {};
const holder: { queryResult: any } = { queryResult: null };
const context = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop', isMobile: () => false, isTouchDevice: () => false },
localStorage,
document: {
getElementById: (id: string) => elements[id] ?? null,
querySelector: () => holder.queryResult,
},
console,
escapeHtml: (s: string) => String(s),
});
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
const app = new (CodemanApp as any)();
return { app, localStorage, elements, holder };
}
describe('shortcut settings persistence and capture', () => {
it('persists overrides under the app-settings storage key and round-trips through the cache', () => {
const { app, localStorage } = loadSettingsHarness();
app.toggleShortcutEnabled('close-session', false);
// Written to the SAME key loadAppSettingsFromStorage() reads (NOT the
// legacy 'codeman:settings' key), and the in-memory cache stays coherent.
const raw = localStorage.getItem('codeman-app-settings');
expect(raw).toBeTruthy();
expect(JSON.parse(raw!).shortcutOverrides['close-session']).toMatchObject({ disabled: true });
expect(localStorage.getItem('codeman:settings')).toBeNull();
expect(app.readShortcutOverridesFromSettings()['close-session']).toMatchObject({ disabled: true });
app.resetShortcutOverride('close-session');
const after = JSON.parse(localStorage.getItem('codeman-app-settings')!);
expect(after.shortcutOverrides['close-session']).toBeUndefined();
expect(app.readShortcutOverridesFromSettings()['close-session']).toBeUndefined();
});
it('captures multi-modifier combos: bare modifier keydowns do not end the capture', () => {
const { app, localStorage, holder } = loadSettingsHarness();
const listeners: Array<(e: any) => void> = [];
const input = {
value: '',
focus: vi.fn(),
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
removeEventListener: vi.fn(),
};
holder.queryResult = input;
app.startShortcutCapture('clear-terminal');
expect(input.value).toBe('Press keys…');
const handler = listeners[0];
// First keydown of Ctrl+Shift+P is 'Control' — must not finalize.
handler({ key: 'Control', ctrlKey: true, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(input.removeEventListener).not.toHaveBeenCalled();
handler({
key: 'P',
code: 'KeyP',
ctrlKey: true,
shiftKey: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
expect(input.removeEventListener).toHaveBeenCalledTimes(1);
const stored = JSON.parse(localStorage.getItem('codeman-app-settings')!);
expect(stored.shortcutOverrides['clear-terminal'].bindings[0]).toMatchObject({
modifiers: ['ctrl', 'shift'],
key: 'P',
code: 'KeyP',
});
});
it('rejects captures without a Ctrl/Cmd/Alt modifier (a bare key would fire while typing)', () => {
const { app, localStorage, holder } = loadSettingsHarness();
const listeners: Array<(e: any) => void> = [];
holder.queryResult = {
value: '',
focus: vi.fn(),
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
removeEventListener: vi.fn(),
};
app.showToast = vi.fn();
app.startShortcutCapture('clear-terminal');
listeners[0]({ key: 'x', code: 'KeyX', preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(localStorage.getItem('codeman-app-settings')).toBeNull();
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
});
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
const { app, elements } = loadSettingsHarness();
elements.appSettingsModal = { querySelectorAll: () => [] };
app.renderShortcutSettingsList = vi.fn();
app.switchSettingsTab('settings-shortcuts');
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
app.switchSettingsTab('settings-display');
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
});
it('renders configurable rows with delegated controls (no inline onclick) and fixed rows read-only', () => {
const { app, elements } = loadSettingsHarness();
const list: any = { innerHTML: '', dataset: {}, addEventListener: vi.fn() };
elements.appSettingsShortcutsList = list;
app.getShortcutRegistry = () => [
{
id: 'clear-terminal',
group: 'Terminal',
label: 'Clear Terminal',
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
action: 'clearTerminal',
},
{ id: 'close-panels', group: 'Panels', label: 'Close Panels', displayBindings: ['Escape'] },
];
app.renderShortcutSettingsList();
expect(list.innerHTML).not.toContain('onclick=');
expect((list.innerHTML.match(/shortcut-capture-btn/g) || []).length).toBe(1);
expect(list.innerHTML).toContain('shortcut-setting-row--fixed');
// Delegated listeners wired exactly once.
expect(list.addEventListener).toHaveBeenCalledTimes(2);
app.renderShortcutSettingsList();
expect(list.addEventListener).toHaveBeenCalledTimes(2);
});
});