mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29efd0e970 | ||
|
|
831af88579 | ||
|
|
752374abc7 | ||
|
|
adfc4fbb1c | ||
|
|
b0b058891c | ||
|
|
4a1ad8d194 | ||
|
|
193ce6348d | ||
|
|
c8ac04662d | ||
|
|
7c2a49d432 | ||
|
|
9b9f2c21e9 |
@@ -1,24 +0,0 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Home screen: make the past-conversation list usable, and let search find past sessions.
|
||||
|
||||
- **#260**: "Resume Conversation" showed 4 rows and then dumped every remaining
|
||||
one into a fixed 240px box, with no ordering or filtering. The list now opens
|
||||
with 10 rows, "Show more"/"Show less" grows and shrinks the box itself (the
|
||||
height cap is class-driven instead of fixed), and the header carries a filter
|
||||
box (matches name, folder, `#case` label and the conversation's prompts), a
|
||||
sort control (recent / name A–Z / folder A–Z, pinned rows still first) and a
|
||||
shown-of-total count. Filtering implies expansion, so every match is visible.
|
||||
- **#261**: the search box could not match a past project by folder name: its
|
||||
session corpus was the live in-memory map, while past sessions come from
|
||||
`/api/sessions/unified`. Search now also harvests a bounded snapshot of that
|
||||
unified list, refreshed OUTSIDE the request path (published by
|
||||
`/api/sessions/unified`, plus a fire-and-forget rebuild when stale), so the
|
||||
search path keeps its no-filesystem-reads property. Results for a closed
|
||||
session resume the conversation instead of trying to select a tab that no
|
||||
longer exists, and are badged `RESUME`. In multi-user mode the snapshot is
|
||||
re-scoped per row on read, matching what `/api/sessions/unified` exposes.
|
||||
|
||||
Reported by @jordan8037310.
|
||||
@@ -1,5 +1,72 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Session rows that name their worktree, a shell keyboard bar for phones, App Settings as one scrolling document, and the Read My Mind modal on phones.
|
||||
- **#265 / #266**: a past session whose directory no longer exists used to report
|
||||
`$HOME` as its working directory, because history rows reconstructed a path by
|
||||
stat-walking the filesystem and fell back to `$HOME` when nothing resolved.
|
||||
Deleting a worktree is the normal end of its life, so every past worktree
|
||||
session collapsed onto the same indistinguishable row. History rows now read
|
||||
the literal `cwd` Claude Code stamps on its own records, out of buffers the
|
||||
scanner had already loaded, so it costs no extra file reads and survives the
|
||||
directory being removed. Sessions that ran in a worktree also carry a
|
||||
`⑂ name · branch` pill in the Resume list and the Cmd+K session manager, and
|
||||
both are searchable by worktree name and branch. Measured on a real install:
|
||||
the cwd was recoverable for 215 of 216 transcripts, 212 of them from the first
|
||||
16KB, and 28 rows that previously read `$HOME` now report their real path.
|
||||
Reported and implemented by @jordan8037310.
|
||||
- **#262**: a shell session now gets its own mobile accessory bar
|
||||
(`Ctrl · Esc · Tab · ↑ · ↓ · ← · → · Paste · ⌄`), with Ctrl as a one-shot
|
||||
modifier: tap it, and the next character goes out as its control byte. That
|
||||
puts Ctrl+C/D/Z/R/L/A/E/W/U/K on a nine-button bar without a button per chord.
|
||||
The modifier is applied on the CJK input path too, where the textarea owns the
|
||||
keyboard and an armed modifier could previously neither fire nor be spent, so
|
||||
it survived until a later keystroke and turned that one into a control byte.
|
||||
Agent sessions keep the existing bar unchanged. Proposed by @DodgyBadger.
|
||||
- **#257**: with several tabs open on a phone, the rightmost ones could not be
|
||||
reached. Selecting a tab never scrolled the strip, and every ambient rebuild
|
||||
reset `scrollLeft` to 0, so a strip the user had just swiped snapped back a
|
||||
moment later. Reported by @DodgyBadger.
|
||||
- **App Settings** is now a left rail acting as a table of contents over one
|
||||
scrolling document instead of 8 tabs that wrapped onto two rows. Nine sections,
|
||||
all mounted at once, so find-in-page works across the whole thing. The model
|
||||
controls stop contradicting each other: the base model lives on cards and "1M
|
||||
context window" is a switch that composes onto it, retiring the old pair of
|
||||
settings that each claimed precedence over the other.
|
||||
- **Read My Mind** suggestions beyond the first are no longer discarded. The
|
||||
alternates render as tappable rows with their kind badge, tapping one swaps it
|
||||
into the editable field without losing an in-progress edit, and Rethink now
|
||||
records the whole shown set as rejected. The modal is sized for phones and
|
||||
reachable from the phone keyboard bar.
|
||||
- The desktop welcome screen carries the open tabs as a rail docked to the left
|
||||
edge, with created and last-active stamps refreshed in place.
|
||||
- The README now documents cloning a GitHub repository straight into a case
|
||||
(**Add Case → Clone Repo**), which shipped in 1.16.2 but was only described in
|
||||
the architecture docs.
|
||||
|
||||
- 5d42f64: Home screen: make the past-conversation list usable, and let search find past sessions.
|
||||
- **#260**: "Resume Conversation" showed 4 rows and then dumped every remaining
|
||||
one into a fixed 240px box, with no ordering or filtering. The list now opens
|
||||
with 10 rows, "Show more"/"Show less" grows and shrinks the box itself (the
|
||||
height cap is class-driven instead of fixed), and the header carries a filter
|
||||
box (matches name, folder, `#case` label and the conversation's prompts), a
|
||||
sort control (recent / name A–Z / folder A–Z, pinned rows still first) and a
|
||||
shown-of-total count. Filtering implies expansion, so every match is visible.
|
||||
- **#261**: the search box could not match a past project by folder name: its
|
||||
session corpus was the live in-memory map, while past sessions come from
|
||||
`/api/sessions/unified`. Search now also harvests a bounded snapshot of that
|
||||
unified list, refreshed OUTSIDE the request path (published by
|
||||
`/api/sessions/unified`, plus a fire-and-forget rebuild when stale), so the
|
||||
search path keeps its no-filesystem-reads property. Results for a closed
|
||||
session resume the conversation instead of trying to select a tab that no
|
||||
longer exists, and are badged `RESUME`. In multi-user mode the snapshot is
|
||||
re-scoped per row on read, matching what `/api/sessions/unified` exposes.
|
||||
|
||||
Reported by @jordan8037310.
|
||||
|
||||
## 1.16.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.16.2 (must match `package.json`)
|
||||
**Version**: 1.16.3 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -210,7 +210,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny buttons are also gated on it (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
||||
|
||||
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set. Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
||||
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
@@ -276,6 +276,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle)
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 430px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
@@ -252,7 +252,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
|
||||
|
||||
| Field | What it does |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. |
|
||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `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`. |
|
||||
@@ -428,6 +428,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
|
||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
|
||||
@@ -124,7 +124,7 @@ Agent use cases this unlocks: a lead session records intentions as the user stat
|
||||
|
||||
1. **Intent store + capture + intent endpoints + skill docs.** Immediately useful to agents even before any UI exists.
|
||||
2. **Context assembler + predictor + predict endpoint + desktop button/modal.** The feature as pitched. The assembler ships with all collectors it can serve from day one (transcript, intent, git, run-summary, siblings); the approvals collector activates when PR #245 lands.
|
||||
3. **Phone accessory key, rethink steering, alternates row.** Part 1 (shipped): the alternates row (tappable, swap into the field without losing edits; Rethink rejects the whole shown set), the phone 🧠 keyboard-accessory key (both bar templates, `rmm-enabled` marker class on the bar), and a phone-sized modal (small dialog, not full-screen). Part 2: rethink steering (the free-text steer note; the API already accepts `steer`).
|
||||
3. **Phone accessory key, rethink steering, alternates row.** Part 1 (shipped): the alternates row (tappable, swap into the field without losing edits; Rethink rejects the whole shown set), the phone 🧠 keyboard-accessory key (both bar templates, `rmm-enabled` marker class on the bar), and a phone-sized modal (small dialog, not full-screen). Part 2 (shipped): rethink steering, the free-text steer note under the suggestions, sent as `steer`, visible whenever Rethink is live (ready and empty-result phases), cleared on each open; the empty-result copy points at the note, and the footer buttons moved to the styled `btn-toolbar` convention (the bare `btn btn-*` classes they shipped with match no CSS in this codebase and rendered as unstyled UA buttons).
|
||||
4. Explicitly later: proactive predict-on-idle (ghost suggestion chip), auto-compaction of `recentPrompts` into `goals` via a cheap model, codex/gemini capture, cross-case "global" intent.
|
||||
|
||||
## Open questions
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ On a Claude session, press the brain button in the header (desktop) or the 🧠
|
||||
|
||||
- **Send** submits it to the session (with Enter).
|
||||
- **Insert** drops it on the CLI composer *without* Enter, so you can edit it in the terminal before sending.
|
||||
- **Rethink** re-runs with everything shown (the field and the alternates) recorded as rejected.
|
||||
- **Rethink** re-runs with everything shown (the field and the alternates) recorded as rejected. An optional steer note below the suggestions ("no, I meant the mobile bug") rides along as your own words, the highest-authority signal the predictor gets; it stays in the field across re-runs until you clear it or reopen the modal.
|
||||
- **Dismiss** closes; nothing happens.
|
||||
|
||||
A prediction takes 5-90 seconds and costs real tokens; one runs per session at a time. If the session is sitting on a permission/question dialog, the suggestion is usually an answer to that dialog: that is intentional.
|
||||
@@ -87,7 +87,7 @@ The `codeman` agent skill documents the same verbs (SKILL.md §3 plus `reference
|
||||
|
||||
## What comes next
|
||||
|
||||
A steer-note input on Rethink ("no, I meant the mobile bug"; the API already accepts `steer`). Explicitly later: proactive predict-on-idle, auto-compaction of the prompt history into goals, non-Claude capture. See the phases section of [`readmymind-plan.md`](readmymind-plan.md).
|
||||
Explicitly later: proactive predict-on-idle, auto-compaction of the prompt history into goals, non-Claude capture. See the phases section of [`readmymind-plan.md`](readmymind-plan.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.2",
|
||||
"version": "1.16.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.2",
|
||||
"version": "1.16.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.2",
|
||||
"version": "1.16.3",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -848,9 +848,11 @@ class CodemanApp {
|
||||
SwipeHandler.init();
|
||||
VoiceInput.init();
|
||||
KeyboardAccessoryBar.init();
|
||||
// Apply keyboard bar mode from settings
|
||||
// Apply keyboard bar mode from settings. Always set it (not only when the
|
||||
// extended bar is on) so the bar's remembered agent-session layout matches
|
||||
// the setting before the first shell session swaps in the terminal bar.
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
KeyboardAccessoryBar.setMode(_kbSettings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
@@ -4531,6 +4533,10 @@ class CodemanApp {
|
||||
this.loadAttachmentHistory?.(sessionId);
|
||||
}
|
||||
this._updateLocalEchoState();
|
||||
// Shell sessions get the terminal keyboard bar, agent sessions the command
|
||||
// bar (issue #262). Also disarms a one-shot Ctrl left over from the tab we
|
||||
// just left, so it can never fire against the session we just opened.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession();
|
||||
|
||||
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
||||
// the async buffer load. Without this, the offset is 0 during the
|
||||
|
||||
@@ -255,12 +255,15 @@
|
||||
'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示',
|
||||
'Predict my next prompt': '预测我的下一条提示',
|
||||
'Reading your mind…': '正在读取您的想法…',
|
||||
'No suggestion this time. Rethink to try again.': '这次没有建议。点击「重想」再试一次。',
|
||||
'No suggestion this time. Add a steer note and Rethink to try again.':
|
||||
'这次没有建议。可添加引导备注后点击「重想」再试一次。',
|
||||
Rethink: '重想',
|
||||
Insert: '插入',
|
||||
"Put the text on the session's composer without submitting it": '将文本放入会话输入框但不提交',
|
||||
'Predicted prompt, editable': '预测的提示,可编辑',
|
||||
'Use this suggestion instead': '改用此建议',
|
||||
"Steer the rethink, e.g. 'no, I meant the mobile bug'": '引导重想,例如:"不,我是指移动端的问题"',
|
||||
'Steer note for Rethink': '重想的引导备注',
|
||||
'Select a session first': '请先选择一个会话',
|
||||
'Read My Mind works on Claude sessions only': '读心术仅适用于 Claude 会话',
|
||||
'Prompt sent': '提示已发送',
|
||||
|
||||
@@ -2894,13 +2894,23 @@
|
||||
<div class="readmymind-why" id="readMyMindWhy" data-i18n-skip></div>
|
||||
<div class="readmymind-alternates" id="readMyMindAlternates" data-i18n-skip style="display:none"></div>
|
||||
</div>
|
||||
<div class="readmymind-error" style="display:none">No suggestion this time. Rethink to try again.</div>
|
||||
<div class="readmymind-error" style="display:none">No suggestion this time. Add a steer note and Rethink to try again.</div>
|
||||
<!-- Rethink steer note: the user's own words about what they actually
|
||||
meant, sent as `steer` with the next Rethink. Shown whenever
|
||||
Rethink is live (ready AND empty-result phases), hidden while a
|
||||
prediction runs. -->
|
||||
<div class="readmymind-steer-row" id="readMyMindSteerRow" style="display:none">
|
||||
<input type="text" id="readMyMindSteer" class="readmymind-steer-input" maxlength="2000" placeholder="Steer the rethink, e.g. 'no, I meant the mobile bug'" aria-label="Steer note for Rethink" onkeydown="if(event.key==='Enter')app.rethinkReadMyMind()">
|
||||
</div>
|
||||
</div>
|
||||
<!-- btn-toolbar, not bare "btn btn-*": no .btn/.btn-secondary rule exists
|
||||
in this codebase, so the bare classes render as unstyled UA buttons.
|
||||
btn-toolbar is the convention every other modal footer uses. -->
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="app.closeReadMyMind()">Dismiss</button>
|
||||
<button class="btn btn-secondary" id="readMyMindRethink" onclick="app.rethinkReadMyMind()">Rethink</button>
|
||||
<button class="btn btn-secondary" onclick="app.sendReadMyMind(false)" title="Put the text on the session's composer without submitting it">Insert</button>
|
||||
<button class="btn btn-primary" onclick="app.sendReadMyMind(true)">Send</button>
|
||||
<button class="btn-toolbar" onclick="app.closeReadMyMind()">Dismiss</button>
|
||||
<button class="btn-toolbar" id="readMyMindRethink" onclick="app.rethinkReadMyMind()">Rethink</button>
|
||||
<button class="btn-toolbar" onclick="app.sendReadMyMind(false)" title="Put the text on the session's composer without submitting it">Insert</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.sendReadMyMind(true)">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
* SHELL sessions get their own layout automatically (issue #262): Ctrl, Esc, Tab,
|
||||
* four arrows, paste, dismiss. Ctrl is a ONE-SHOT modifier: arm it, type a
|
||||
* character on the system keyboard, and terminal-ui.js's onData hook swaps the
|
||||
* character for its control byte (ctrlByteFor) and disarms. That is what makes
|
||||
* Ctrl+C/D/Z/R/L/A/E/W/U/K reachable without a button per chord. It resets on
|
||||
* use, on a second tap, on any other accessory key, on a session switch
|
||||
* (refreshForActiveSession) and when the keyboard is dismissed (hide).
|
||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||
* by Link Existing and the extended mobile keyboard bar.
|
||||
*
|
||||
@@ -414,12 +421,58 @@ const PathPicker = {
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Control byte a terminal sends for Ctrl+<char> (issue #262).
|
||||
*
|
||||
* Returns null for characters with no control equivalent (digits, most
|
||||
* punctuation): the caller then sends the character unchanged, matching a
|
||||
* hardware keyboard where Ctrl+7 just types "7".
|
||||
*
|
||||
* `code & 0x1f` covers both ranges a terminal maps: @A-Z[\]^_ (64-95 → 0-31)
|
||||
* and a-z (97-122 → 1-26). Space and ? are the two conventional extras
|
||||
* (Ctrl+Space = NUL, Ctrl+? = DEL) and can't come from the mask.
|
||||
*/
|
||||
function ctrlByteFor(char) {
|
||||
if (typeof char !== 'string' || char.length !== 1) return null;
|
||||
const code = char.charCodeAt(0);
|
||||
if (code === 32) return '\x00';
|
||||
if (code === 63) return '\x7f';
|
||||
if ((code >= 64 && code <= 95) || (code >= 97 && code <= 122)) {
|
||||
return String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an armed one-shot Ctrl to one chunk of terminal input.
|
||||
* Returns `{ data, consumed }`, where `consumed` tells the bar to disarm.
|
||||
*
|
||||
* Multi-character chunks (pastes, escape sequences, IME commits) have no
|
||||
* single key to modify, but they still spend the modifier: leaving it armed
|
||||
* would silently turn the NEXT innocent keystroke into a control byte.
|
||||
*/
|
||||
function applyOneShotCtrl(data) {
|
||||
if (typeof data !== 'string' || data.length === 0) return { data, consumed: false };
|
||||
if (data.length === 1) {
|
||||
const byte = ctrlByteFor(data);
|
||||
return { data: byte === null ? data : byte, consumed: true };
|
||||
}
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
const KeyboardAccessoryBar = {
|
||||
element: null,
|
||||
_mode: 'simple', // 'simple' or 'extended'
|
||||
// Layout currently in the DOM: 'simple' | 'extended' | 'shell'.
|
||||
_mode: 'simple',
|
||||
// Layout the user picked for AGENT sessions ('simple' | 'extended', the
|
||||
// extendedKeyboardBar setting). Shell sessions override it with the shell
|
||||
// bar; this is what we come back to when they switch to an agent tab.
|
||||
_baseMode: 'simple',
|
||||
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
|
||||
_ctrlArmed: false,
|
||||
|
||||
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
|
||||
_simpleButtons: `
|
||||
@@ -449,6 +502,45 @@ const KeyboardAccessoryBar = {
|
||||
</svg>
|
||||
</button>`,
|
||||
|
||||
/** HTML for shell mode (issue #262): terminal controls instead of agent
|
||||
* commands. Ctrl is a one-shot modifier rather than one button per chord,
|
||||
* which is what puts Ctrl+C/D/Z/R/L/A/E/W/U/K on a 9-button bar. */
|
||||
_shellButtons: `
|
||||
<button class="accessory-btn accessory-btn-ctrl" data-action="ctrl" title="Ctrl, then tap a key" aria-pressed="false">Ctrl</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>`,
|
||||
|
||||
/** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
|
||||
_extendedButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
@@ -519,7 +611,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
@@ -535,14 +627,91 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Switch between 'simple' and 'extended' button layouts */
|
||||
/** Pick the layout the user wants for AGENT sessions ('simple' | 'extended',
|
||||
* the extendedKeyboardBar setting). A shell session keeps the shell bar;
|
||||
* the preference is remembered and applied on the next agent tab. */
|
||||
setMode(mode) {
|
||||
if (mode === this._mode || !this.element) return;
|
||||
this._baseMode = mode === 'extended' ? 'extended' : 'simple';
|
||||
this._applyLayout(this._resolveMode());
|
||||
},
|
||||
|
||||
/** Re-resolve the layout after the active session changed (issue #262):
|
||||
* shell sessions get the terminal bar, everything else the agent bar. Also
|
||||
* disarms Ctrl, because a modifier armed on one session must never fire on
|
||||
* the next one. */
|
||||
refreshForActiveSession() {
|
||||
this.clearCtrl();
|
||||
this._applyLayout(this._resolveMode());
|
||||
},
|
||||
|
||||
/** Which layout the current state calls for. */
|
||||
_resolveMode() {
|
||||
return this._isShellSession() ? 'shell' : this._baseMode;
|
||||
},
|
||||
|
||||
_isShellSession() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return false;
|
||||
return app.sessions?.get(app.activeSessionId)?.mode === 'shell';
|
||||
},
|
||||
|
||||
/** Swap the button set in the DOM. */
|
||||
_applyLayout(mode) {
|
||||
if (!this.element || mode === this._mode) return;
|
||||
this._mode = mode;
|
||||
this.clearConfirm();
|
||||
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
// Reset before the rewrite: _setCtrl() styles the button it can find, and
|
||||
// the one holding the armed class is about to be replaced.
|
||||
this.clearCtrl();
|
||||
this.element.innerHTML =
|
||||
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
},
|
||||
|
||||
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
|
||||
// Tap Ctrl, then type a character on the system keyboard: the character is
|
||||
// replaced by its control byte and Ctrl disarms. Tapping Ctrl again cancels.
|
||||
// The interception lives in the terminal onData handler (terminal-ui.js),
|
||||
// which is where system-keyboard input arrives on a phone. A keydown hook
|
||||
// would miss it, since virtual keyboards report no usable key events.
|
||||
|
||||
/** Is the one-shot Ctrl waiting for a key? */
|
||||
isCtrlArmed() {
|
||||
return this._ctrlArmed === true;
|
||||
},
|
||||
|
||||
/** Arm/cancel the one-shot Ctrl (the Ctrl button toggles). */
|
||||
toggleCtrl() {
|
||||
this._setCtrl(!this._ctrlArmed);
|
||||
},
|
||||
|
||||
/** Disarm: used by session switch, keyboard dismissal and every other key. */
|
||||
clearCtrl() {
|
||||
if (this._ctrlArmed) this._setCtrl(false);
|
||||
},
|
||||
|
||||
_setCtrl(on) {
|
||||
this._ctrlArmed = !!on;
|
||||
const btn = this.element?.querySelector('[data-action="ctrl"]');
|
||||
if (btn) {
|
||||
btn.classList.toggle('armed', this._ctrlArmed);
|
||||
btn.setAttribute('aria-pressed', this._ctrlArmed ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply an armed Ctrl to a chunk of typed input and disarm.
|
||||
* Returns the data unchanged (and leaves the modifier alone) when Ctrl is
|
||||
* not armed, so the caller can pipe every keystroke through it.
|
||||
*/
|
||||
consumeCtrl(data) {
|
||||
if (!this._ctrlArmed) return data;
|
||||
const result = applyOneShotCtrl(data);
|
||||
if (result.consumed) this.clearCtrl();
|
||||
return result.data;
|
||||
},
|
||||
|
||||
/** Exposed for tests: pure char to control byte mapping. */
|
||||
ctrlByteFor,
|
||||
|
||||
_confirmTimer: null,
|
||||
_confirmAction: null,
|
||||
|
||||
@@ -550,7 +719,15 @@ const KeyboardAccessoryBar = {
|
||||
handleAction(action, btn) {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
|
||||
// Any key other than Ctrl itself spends the modifier. It is a one-shot for
|
||||
// the next TYPED character, so an accessory key tapped in between (Esc, an
|
||||
// arrow, paste) must not leave it armed to bite the keystroke after that.
|
||||
if (action !== 'ctrl') this.clearCtrl();
|
||||
|
||||
switch (action) {
|
||||
case 'ctrl':
|
||||
this.toggleCtrl();
|
||||
break;
|
||||
case 'scroll-up':
|
||||
this.sendKey('\x1b[A');
|
||||
break;
|
||||
@@ -805,6 +982,10 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
/** Hide the accessory bar */
|
||||
hide() {
|
||||
// The bar goes away with the keyboard, so an armed Ctrl has nothing left
|
||||
// to modify, and a modifier the user can no longer see must not survive
|
||||
// to the next time they open the keyboard.
|
||||
this.clearCtrl();
|
||||
if (this.element) {
|
||||
this.element.classList.remove('visible');
|
||||
}
|
||||
|
||||
@@ -1175,6 +1175,18 @@ html.mobile-init .file-browser-panel {
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
/* Armed one-shot Ctrl (shell bar, issue #262). Phone palette is hardcoded in
|
||||
this block, so the state needs its own entry here. Three classes beat the
|
||||
plain .accessory-btn rules; the light-skin rule at the bottom of this file
|
||||
is higher still at (0,3,1) and is excluded by hand there, not outranked. */
|
||||
.accessory-btn.accessory-btn-ctrl.armed {
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.9);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.45);
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
@@ -1324,20 +1336,22 @@ html.mobile-init .file-browser-panel {
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
/* Four footer buttons on a narrow phone: let them wrap instead of clipping,
|
||||
and give buttons + alternate rows finger-sized targets. */
|
||||
and give buttons + alternate rows finger-sized targets. The flex row
|
||||
itself comes from the base rule in styles.css. */
|
||||
.readmymind-modal .modal-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.readmymind-modal .modal-footer .btn {
|
||||
.readmymind-modal .modal-footer .btn-toolbar {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
}
|
||||
.readmymind-alt {
|
||||
min-height: 38px;
|
||||
}
|
||||
.readmymind-steer-input {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
/* Modal safe area padding - all sides for full-screen modals */
|
||||
.ios-device .modal-content {
|
||||
@@ -2947,7 +2961,13 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
|
||||
/* `.accessory-btn:not(.armed)` on purpose: this selector is (0,3,1) — `:is()`
|
||||
takes the specificity of its most specific argument, and `.btn-toolbar
|
||||
.btn-shell` is two classes — so it OUTRANKS the (0,3,0) armed-Ctrl rules in
|
||||
both stylesheets and repainted the armed modifier back to a resting button on
|
||||
all four light skins. Excluding the state here fixes phone and tablet at once;
|
||||
adding a class to the armed rules would only have moved the tie. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn:not(.armed)) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* suggestions render as tappable alternate rows that swap into the field
|
||||
* without losing edits. Buttons are Send (with Enter), Insert (drop on the CLI
|
||||
* composer WITHOUT Enter, for editing), Rethink (re-run with the whole shown
|
||||
* set, main + alternates, recorded as rejected), Dismiss.
|
||||
* set, main + alternates, recorded as rejected, plus the optional free-text
|
||||
* steer note, e.g. "no, I meant the mobile bug", sent as `steer`), Dismiss.
|
||||
*
|
||||
* Suggestions are NEVER auto-sent: the explicit click here is the security
|
||||
* boundary for observed/injectable predictor inputs, so suggestion text is
|
||||
@@ -47,8 +48,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Read My Mind works on Claude sessions only', 'warning');
|
||||
return;
|
||||
}
|
||||
// Rethink memory resets on each open (a fresh open is a fresh question).
|
||||
// Rethink memory resets on each open (a fresh open is a fresh question),
|
||||
// and the steer note resets with it.
|
||||
this._rmm = { sessionId, suggestions: [], selected: 0, rejected: [], busy: false };
|
||||
const steer = document.getElementById('readMyMindSteer');
|
||||
if (steer) steer.value = '';
|
||||
document.getElementById('readMyMindModal')?.classList.add('active');
|
||||
this._readMyMindPredict();
|
||||
},
|
||||
@@ -65,7 +69,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.busy = true;
|
||||
this._rmmSetPhase('loading');
|
||||
|
||||
const body = state.rejected.length > 0 ? { rejected: state.rejected.slice(-10) } : {};
|
||||
const body = {};
|
||||
if (state.rejected.length > 0) body.rejected = state.rejected.slice(-10);
|
||||
// The steer note rides every re-run while it stays in the field: what the
|
||||
// user sees in the box is what the predictor gets. Empty on first open
|
||||
// (openReadMyMind clears it), so a plain predict sends neither key.
|
||||
const steer = document.getElementById('readMyMindSteer')?.value.trim() ?? '';
|
||||
if (steer) body.steer = steer.slice(0, 2000);
|
||||
const data = await this._apiJson(`/api/sessions/${state.sessionId}/readmymind`, { method: 'POST', body });
|
||||
|
||||
// The modal may have been dismissed (or reopened for another session) while
|
||||
@@ -176,7 +186,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Re-run with the whole shown set (main + alternates) recorded as rejected:
|
||||
* the user saw every row and asked for something else. */
|
||||
* the user saw every row and asked for something else. The steer note (if
|
||||
* any) is read from the field by _readMyMindPredict itself. */
|
||||
rethinkReadMyMind() {
|
||||
const state = this._rmm;
|
||||
if (!state || state.busy) return;
|
||||
@@ -193,6 +204,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelector('.readmymind-loading').style.display = phase === 'loading' ? '' : 'none';
|
||||
modal.querySelector('.readmymind-result').style.display = phase === 'ready' ? '' : 'none';
|
||||
modal.querySelector('.readmymind-error').style.display = phase === 'error' ? '' : 'none';
|
||||
// The steer note belongs to Rethink, so it shows wherever Rethink is live:
|
||||
// the ready phase AND the empty-result phase (typed text survives the
|
||||
// loading round-trip, only the row's visibility toggles).
|
||||
const steerRow = document.getElementById('readMyMindSteerRow');
|
||||
if (steerRow) steerRow.style.display = phase === 'loading' ? 'none' : '';
|
||||
const rethinkBtn = document.getElementById('readMyMindRethink');
|
||||
if (rethinkBtn) rethinkBtn.disabled = phase === 'loading';
|
||||
},
|
||||
|
||||
@@ -10901,6 +10901,58 @@ kbd {
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono-font, monospace);
|
||||
}
|
||||
/* Rethink steer note: the user's own words about what they actually meant,
|
||||
sent as `steer` on the next re-run (readmymind-ui.js _readMyMindPredict).
|
||||
Dashed border marks it as the optional side channel, distinct from the
|
||||
primary suggestion field above; it solidifies on focus. */
|
||||
.readmymind-steer-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.readmymind-steer-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px dashed var(--control-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.readmymind-steer-input:focus {
|
||||
outline: none;
|
||||
border-style: solid;
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
.readmymind-steer-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
/* Footer row: the buttons are btn-toolbar (display: flex, block-level), so
|
||||
without this rule the four of them stack vertically. Mirrors the
|
||||
runSummaryModal footer; the ≤430px block in mobile.css adds wrapping. */
|
||||
.readmymind-modal .modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
/* Send keeps the primary accent: the skin block's bare .btn-toolbar rule is
|
||||
(0,2,1) under html:not([data-skin="og"]) and outranks the base
|
||||
.btn-toolbar.btn-primary (0,2,0) — the specificity trap CLAUDE.md documents
|
||||
for mobile.css — so the accent is re-asserted here at (0,4,0). Scoped to
|
||||
this modal on purpose; un-greying every btn-primary on the new skins is a
|
||||
design call, not this feature's. */
|
||||
.readmymind-modal .modal-footer .btn-toolbar.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.readmymind-modal .modal-footer .btn-toolbar.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Keyboard-accessory 🧠 key: the phone surface for the same opt-in setting
|
||||
(the header 🧠 button stays phone-hidden in mobile.css). The key ships in
|
||||
@@ -12239,6 +12291,20 @@ body.touch-device.cjk-input-visible .main {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* Armed one-shot Ctrl (shell bar, issue #262). Three classes on purpose, to beat
|
||||
the plain .accessory-btn rules. It still cannot outrank the light-skin
|
||||
compatibility rule in mobile.css, which repaints every .accessory-btn at
|
||||
(0,3,1) — `:is()` inherits its most specific argument — so that rule excludes
|
||||
`.armed` by hand. Without the exclusion the modifier looks unarmed on the four
|
||||
light skins, which is worse than having no armed style at all. */
|
||||
.accessory-btn.accessory-btn-ctrl.armed {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
(function (global) {
|
||||
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
|
||||
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
|
||||
// Pointer and focus reports xterm emits through onData on the terminal's OWN
|
||||
// initiative, with no key pressed: SGR mouse (DECSET 1006, also 1016), legacy
|
||||
// X10 mouse (DECSET 1000 — three raw bytes after CSI M) and focus in/out
|
||||
// (DECSET 1004). They are not query REPLIES, so the query-response filter
|
||||
// above does not match them, and they must keep reaching the PTY. What they
|
||||
// must NOT do is stand in for a keystroke: see isTerminalFocusOrMouseReport.
|
||||
const MOUSE_SGR_REPORT_PATTERN = /^\x1b\[<\d+;\d+;\d+[Mm]$/;
|
||||
const MOUSE_X10_REPORT_PATTERN = /^\x1b\[M[\s\S]{3}$/;
|
||||
const FOCUS_REPORT_PATTERN = /^\x1b\[[IO]$/;
|
||||
// Grace window after a manual scroll-up gesture during which sticky-scroll is
|
||||
// suppressed, so high-frequency Codex status redraws don't snap the viewport
|
||||
// back to the bottom while the user is inspecting earlier output.
|
||||
@@ -106,6 +115,30 @@
|
||||
return isTerminalQueryResponse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the terminal generate this chunk itself, rather than a human pressing a
|
||||
* key? True for mouse and focus reports (issue #262).
|
||||
*
|
||||
* Consumers that treat one onData chunk as "the next keystroke" must skip
|
||||
* these. The one-shot Ctrl modifier is why this exists, and the MOUSE half is
|
||||
* the live one: a shell session keeps the narrow scrollback strip, so mouse
|
||||
* DECSETs reach the browser and anything the user runs that enables tracking
|
||||
* (vim, htop, less) turns a tap into `\x1b[<0;31;23M`. Measured in a real
|
||||
* shell session: with Ctrl armed, one tap on the terminal spent it silently.
|
||||
*
|
||||
* Focus reports are the same class and cost nothing to cover, but they cannot
|
||||
* reach xterm today: `FOCUS_ESCAPE_FILTER` in session.ts strips `\x1b[?1004h`
|
||||
* (and the reports themselves) from every PTY read, so `sendFocusMode` never
|
||||
* turns on. Were that filter to go, the Ctrl button would spend the modifier
|
||||
* on its OWN refocus — the bar refocuses the terminal after every key so the
|
||||
* keyboard stays open, and that refocus emits `\x1b[I`.
|
||||
*/
|
||||
function isTerminalFocusOrMouseReport(data) {
|
||||
return (
|
||||
FOCUS_REPORT_PATTERN.test(data) || MOUSE_SGR_REPORT_PATTERN.test(data) || MOUSE_X10_REPORT_PATTERN.test(data)
|
||||
);
|
||||
}
|
||||
|
||||
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
|
||||
// theme, so default behavior is unchanged. Shared at module scope and exported on the
|
||||
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
|
||||
@@ -134,6 +167,7 @@
|
||||
global.CodemanTerminalInput = {
|
||||
isTerminalQueryResponse,
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
isTerminalFocusOrMouseReport,
|
||||
isComposerNavKey,
|
||||
classifyPredictInput,
|
||||
isCodexComposerRow,
|
||||
@@ -930,6 +964,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── One-shot Ctrl (mobile shell bar, issue #262) ──
|
||||
// A virtual keyboard reports no usable key events, so a keydown hook
|
||||
// would never see the character the modifier applies to: it arrives
|
||||
// here as onData text. Sits AFTER the query-response filter so xterm's
|
||||
// own DA/CPR replies can never spend the modifier, and BEFORE every
|
||||
// send path so the control byte follows the normal control-char route
|
||||
// (immediate flush, local-echo state cleared).
|
||||
//
|
||||
// Mouse and focus reports are skipped rather than suppressed: they are
|
||||
// real bytes the PTY still needs, they just were not typed by anyone.
|
||||
// A shell session passes mouse DECSETs through, so with vim or htop
|
||||
// running, one tap on the terminal used to spend the modifier silently
|
||||
// (measured against a real shell). See isTerminalFocusOrMouseReport.
|
||||
if (
|
||||
typeof KeyboardAccessoryBar !== 'undefined' &&
|
||||
KeyboardAccessoryBar.isCtrlArmed?.() &&
|
||||
!window.CodemanTerminalInput?.isTerminalFocusOrMouseReport(data)
|
||||
) {
|
||||
data = KeyboardAccessoryBar.consumeCtrl(data);
|
||||
}
|
||||
|
||||
this._lastTerminalData = { data, time: performance.now() };
|
||||
|
||||
// ── Local Echo Pass-through ──
|
||||
@@ -2767,6 +2823,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
_crashDiag.log(`CJK send DROP no-session len=${text.length}`);
|
||||
return;
|
||||
}
|
||||
// ── One-shot Ctrl (mobile shell bar, issue #262) ──
|
||||
// While the CJK field is visible it OWNS the keyboard: onData returns early
|
||||
// for everything it swallows, and the focus router even redirects
|
||||
// terminal.focus() into it — which is where the accessory bar sends focus
|
||||
// after every key. So the onData hook never sees these keystrokes, and an
|
||||
// armed modifier could neither fire NOR be spent: it survived until a
|
||||
// session switch and then turned an innocent keystroke into a control byte.
|
||||
// This is the module's single choke point to the PTY, so applying it here
|
||||
// covers typed characters, IME flushes, Enter, backspace and arrows at once.
|
||||
// Same policy as the onData hook: the next single character is modified,
|
||||
// anything longer merely spends the modifier.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined' && KeyboardAccessoryBar.isCtrlArmed?.()) {
|
||||
text = KeyboardAccessoryBar.consumeCtrl(text);
|
||||
}
|
||||
// Bypasses onData (like insertTerminalText): predictions cannot see this
|
||||
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||
_crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* @fileoverview Shell-specific mobile keyboard bar and its one-shot Ctrl
|
||||
* modifier (issue #262).
|
||||
*
|
||||
* The bar is a `const` singleton in a non-module script, so it is loaded with
|
||||
* `vm` against a small fake DOM (no jsdom in this repo), the same approach as
|
||||
* test/path-picker-ui.test.ts. What matters here is the state machine: which
|
||||
* layout a session gets, when the modifier arms, what byte a keystroke turns
|
||||
* into, and every path that must disarm it. Behavior against a real shell
|
||||
* (Ctrl+C reaching the PTY) is covered in test/mobile/keyboard.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||
|
||||
type TerminalInput = { isTerminalFocusOrMouseReport(data: string): boolean };
|
||||
type TerminalModule = {
|
||||
terminalInput: TerminalInput;
|
||||
CodemanApp: { prototype: Record<string, (...args: never[]) => unknown> };
|
||||
bar: Bar;
|
||||
};
|
||||
let terminalModule: TerminalModule | null = null;
|
||||
|
||||
/**
|
||||
* terminal-ui.js in a vm, with the REAL accessory bar in the same script scope
|
||||
* (it is a `const` singleton, so only a shared scope makes the bare
|
||||
* `KeyboardAccessoryBar` reference in the CJK path resolve). Its IIFE only
|
||||
* needs a window to hang `CodemanTerminalInput` on, but the rest of the file
|
||||
* assigns to CodemanApp.prototype at top level, so constants.js + app.js load
|
||||
* first — the same recipe as test/local-echo-codex-gating.test.ts.
|
||||
*/
|
||||
function loadTerminalModule(): TerminalModule {
|
||||
if (terminalModule) return terminalModule;
|
||||
const read = (file: string) => readFileSync(resolve(`src/web/public/${file}`), 'utf8');
|
||||
const windowStub: Record<string, unknown> = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
URLSearchParams,
|
||||
document: { addEventListener: vi.fn(), documentElement: { dataset: {} }, getElementById: () => null },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: windowStub,
|
||||
MobileDetection: { isTouchDevice: () => true, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${keyboardSource}\n${read('app.js')}\n${terminalSource}\n` +
|
||||
`globalThis.__CodemanApp = CodemanApp; globalThis.__bar = KeyboardAccessoryBar;`,
|
||||
context
|
||||
);
|
||||
const exported = context as unknown as { __CodemanApp: TerminalModule['CodemanApp']; __bar: Bar };
|
||||
terminalModule = {
|
||||
terminalInput: (windowStub as { CodemanTerminalInput?: TerminalInput }).CodemanTerminalInput!,
|
||||
CodemanApp: exported.__CodemanApp,
|
||||
bar: exported.__bar,
|
||||
};
|
||||
return terminalModule;
|
||||
}
|
||||
|
||||
function loadTerminalInput(): TerminalInput {
|
||||
return loadTerminalModule().terminalInput;
|
||||
}
|
||||
|
||||
type FakeButton = {
|
||||
dataset: { action: string };
|
||||
classList: { has: Set<string>; toggle(name: string, on: boolean): void; contains(name: string): boolean };
|
||||
attrs: Record<string, string>;
|
||||
setAttribute(name: string, value: string): void;
|
||||
};
|
||||
|
||||
function fakeButton(action: string): FakeButton {
|
||||
const has = new Set<string>();
|
||||
return {
|
||||
dataset: { action },
|
||||
classList: {
|
||||
has,
|
||||
toggle(name: string, on: boolean) {
|
||||
if (on) has.add(name);
|
||||
else has.delete(name);
|
||||
},
|
||||
contains: (name: string) => has.has(name),
|
||||
},
|
||||
attrs: {},
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attrs[name] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Fake bar element: tracks the button set parsed out of the assigned HTML. */
|
||||
function fakeBarElement() {
|
||||
let html = '';
|
||||
let buttons = new Map<string, FakeButton>();
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
className: '',
|
||||
classList: {
|
||||
add: (name: string) => classes.add(name),
|
||||
remove: (name: string) => classes.delete(name),
|
||||
contains: (name: string) => classes.has(name),
|
||||
// init() calls syncReadMyMind(), which toggles the 🧠 marker class on the
|
||||
// bar with an explicit force argument.
|
||||
toggle: (name: string, force?: boolean) => {
|
||||
const on = force === undefined ? !classes.has(name) : force;
|
||||
if (on) classes.add(name);
|
||||
else classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
get innerHTML() {
|
||||
return html;
|
||||
},
|
||||
set innerHTML(next: string) {
|
||||
html = next;
|
||||
buttons = new Map();
|
||||
for (const match of next.matchAll(/data-action="([^"]+)"/g)) {
|
||||
buttons.set(match[1], fakeButton(match[1]));
|
||||
}
|
||||
},
|
||||
get actions() {
|
||||
return [...buttons.keys()];
|
||||
},
|
||||
querySelector(selector: string) {
|
||||
const match = /\[data-action="([^"]+)"\]/.exec(selector);
|
||||
return match ? (buttons.get(match[1]) ?? null) : null;
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
type Bar = {
|
||||
element: ReturnType<typeof fakeBarElement>;
|
||||
_mode: string;
|
||||
init(): void;
|
||||
setMode(mode: string): void;
|
||||
refreshForActiveSession(): void;
|
||||
handleAction(action: string, btn?: unknown): void;
|
||||
isCtrlArmed(): boolean;
|
||||
toggleCtrl(): void;
|
||||
clearCtrl(): void;
|
||||
consumeCtrl(data: string): string;
|
||||
ctrlByteFor(char: string): string | null;
|
||||
hide(): void;
|
||||
show(): void;
|
||||
};
|
||||
|
||||
function loadBar(sessionMode = 'claude') {
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
sessions: new Map<string, { mode: string }>([['session-1', { mode: sessionMode }]]),
|
||||
terminal: { focus: vi.fn() },
|
||||
};
|
||||
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, catch: () => {} }));
|
||||
const barElement = fakeBarElement();
|
||||
const context = vm.createContext({
|
||||
app,
|
||||
MobileDetection: { isTouchDevice: () => true },
|
||||
URLSearchParams,
|
||||
fetch: fetchMock,
|
||||
document: {
|
||||
createElement: () => barElement,
|
||||
querySelector: () => ({ parentNode: { insertBefore: vi.fn() } }),
|
||||
},
|
||||
setTimeout: (fn: () => void) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
vm.runInContext(`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar;`, context, {
|
||||
filename: 'keyboard-accessory.js',
|
||||
});
|
||||
const bar = (context as unknown as { __bar: Bar }).__bar;
|
||||
bar.init();
|
||||
return { app, bar, barElement, fetchMock };
|
||||
}
|
||||
|
||||
describe('ctrlByteFor: character to control byte', () => {
|
||||
const { bar } = loadBar();
|
||||
|
||||
it.each([
|
||||
['c', '\x03'], // interrupt
|
||||
['d', '\x04'], // EOF
|
||||
['z', '\x1a'], // suspend
|
||||
['r', '\x12'], // reverse search
|
||||
['l', '\x0c'], // clear
|
||||
['a', '\x01'],
|
||||
['e', '\x05'],
|
||||
['w', '\x17'],
|
||||
['u', '\x15'],
|
||||
['k', '\x0b'],
|
||||
])('maps %s to its control byte', (char, byte) => {
|
||||
expect(bar.ctrlByteFor(char)).toBe(byte);
|
||||
});
|
||||
|
||||
it('maps uppercase the same as lowercase (Ctrl+C == Ctrl+c)', () => {
|
||||
expect(bar.ctrlByteFor('C')).toBe('\x03');
|
||||
expect(bar.ctrlByteFor('D')).toBe('\x04');
|
||||
});
|
||||
|
||||
it('maps the punctuation controls a terminal defines', () => {
|
||||
expect(bar.ctrlByteFor('@')).toBe('\x00');
|
||||
expect(bar.ctrlByteFor('[')).toBe('\x1b'); // Ctrl+[ is Escape
|
||||
expect(bar.ctrlByteFor('\\')).toBe('\x1c');
|
||||
expect(bar.ctrlByteFor(']')).toBe('\x1d');
|
||||
expect(bar.ctrlByteFor('^')).toBe('\x1e');
|
||||
expect(bar.ctrlByteFor('_')).toBe('\x1f');
|
||||
expect(bar.ctrlByteFor(' ')).toBe('\x00'); // Ctrl+Space = NUL
|
||||
expect(bar.ctrlByteFor('?')).toBe('\x7f'); // Ctrl+? = DEL
|
||||
});
|
||||
|
||||
it('returns null for characters with no control equivalent', () => {
|
||||
// A hardware keyboard types these straight through under Ctrl.
|
||||
for (const char of ['1', '9', '.', ',', '/', '-', '=', 'é']) {
|
||||
expect(bar.ctrlByteFor(char)).toBeNull();
|
||||
}
|
||||
expect(bar.ctrlByteFor('ab')).toBeNull();
|
||||
expect(bar.ctrlByteFor('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell keyboard bar selection', () => {
|
||||
it('gives a shell session the terminal bar', () => {
|
||||
const { bar, barElement } = loadBar('shell');
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('shell');
|
||||
expect(barElement.actions).toEqual([
|
||||
'ctrl',
|
||||
'esc',
|
||||
'tab',
|
||||
'scroll-up',
|
||||
'scroll-down',
|
||||
'arrow-left',
|
||||
'arrow-right',
|
||||
'paste',
|
||||
'dismiss',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity'])('leaves a %s session on the agent bar', (mode) => {
|
||||
const { bar, barElement } = loadBar(mode);
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('simple');
|
||||
expect(barElement.actions).toContain('init');
|
||||
expect(barElement.actions).not.toContain('ctrl');
|
||||
});
|
||||
|
||||
it('remembers the extended-bar preference across a shell session', () => {
|
||||
const { app, bar, barElement } = loadBar('claude');
|
||||
bar.setMode('extended');
|
||||
expect(bar._mode).toBe('extended');
|
||||
|
||||
app.sessions.set('shell-1', { mode: 'shell' });
|
||||
app.activeSessionId = 'shell-1';
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('shell');
|
||||
|
||||
// Settings saved while the shell bar is up must not yank it away...
|
||||
bar.setMode('extended');
|
||||
expect(bar._mode).toBe('shell');
|
||||
|
||||
// ...and switching back to the agent session restores the user's choice.
|
||||
app.activeSessionId = 'session-1';
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('extended');
|
||||
expect(barElement.actions).toContain('compact');
|
||||
});
|
||||
|
||||
it('falls back to the agent bar with no active session', () => {
|
||||
const { app, bar } = loadBar('shell');
|
||||
app.activeSessionId = null as unknown as string;
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('simple');
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-shot Ctrl modifier', () => {
|
||||
function shellBar() {
|
||||
const loaded = loadBar('shell');
|
||||
loaded.bar.refreshForActiveSession();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
it('is disarmed until the Ctrl key is tapped', () => {
|
||||
const { bar } = shellBar();
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
expect(bar.consumeCtrl('c')).toBe('c');
|
||||
});
|
||||
|
||||
it('arms visibly and rewrites the next character as its control byte', () => {
|
||||
const { bar, barElement } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
const button = barElement.querySelector('[data-action="ctrl"]')!;
|
||||
expect(button.classList.contains('armed')).toBe(true);
|
||||
expect(button.attrs['aria-pressed']).toBe('true');
|
||||
|
||||
expect(bar.consumeCtrl('c')).toBe('\x03');
|
||||
|
||||
// One shot: spent, and the button says so.
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
expect(button.classList.contains('armed')).toBe(false);
|
||||
expect(button.attrs['aria-pressed']).toBe('false');
|
||||
expect(bar.consumeCtrl('c')).toBe('c');
|
||||
});
|
||||
|
||||
it('sends Ctrl+D for the next key too', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.consumeCtrl('d')).toBe('\x04');
|
||||
});
|
||||
|
||||
it('cancels on a second tap of Ctrl', () => {
|
||||
const { bar, barElement } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
expect(barElement.querySelector('[data-action="ctrl"]')!.classList.contains('armed')).toBe(false);
|
||||
expect(bar.consumeCtrl('c')).toBe('c');
|
||||
});
|
||||
|
||||
it('passes a character with no control byte through unchanged, spending the modifier', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.consumeCtrl('7')).toBe('7');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('spends the modifier on a paste instead of leaving it armed for the next keystroke', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.consumeCtrl('git status')).toBe('git status');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('is cancelled by any other accessory key', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
bar.handleAction('esc');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('is cancelled by a session switch', () => {
|
||||
const { app, bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
|
||||
app.sessions.set('shell-2', { mode: 'shell' });
|
||||
app.activeSessionId = 'shell-2';
|
||||
bar.refreshForActiveSession();
|
||||
|
||||
// Same layout, but the modifier must not survive into the next session.
|
||||
expect(bar._mode).toBe('shell');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('is cancelled when the keyboard is dismissed', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
bar.hide();
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the armed state when the layout is swapped out from under it', () => {
|
||||
const { app, bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
app.sessions.set('agent-1', { mode: 'claude' });
|
||||
app.activeSessionId = 'agent-1';
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('simple');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
expect(bar.consumeCtrl('c')).toBe('c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal input wiring', () => {
|
||||
it('applies the modifier in onData after the query-response filter and before the send paths', () => {
|
||||
const hook = terminalSource.indexOf('KeyboardAccessoryBar.consumeCtrl(data)');
|
||||
const queryFilter = terminalSource.indexOf('shouldSuppressTerminalQueryResponse(data)', hook - 4000);
|
||||
const firstSend = terminalSource.indexOf('this._lastTerminalData', hook - 4000);
|
||||
|
||||
expect(hook).toBeGreaterThan(0);
|
||||
// xterm answers DA/CPR queries through onData as well; letting one of those
|
||||
// spend the modifier would silently eat the user's Ctrl.
|
||||
expect(queryFilter).toBeGreaterThan(0);
|
||||
expect(queryFilter).toBeLessThan(hook);
|
||||
// Every send path (local echo, predictive echo, plain flush) reads `data`
|
||||
// after this point, so the control byte reaches the PTY unchanged.
|
||||
expect(firstSend).toBeGreaterThan(hook);
|
||||
});
|
||||
|
||||
it('guards the hook so a page without the bar (desktop) still types normally', () => {
|
||||
expect(terminalSource).toContain("typeof KeyboardAccessoryBar !== 'undefined'");
|
||||
});
|
||||
|
||||
it('skips terminal-generated focus and mouse reports', () => {
|
||||
// Pins the gate itself: without it the modifier is spent by the `\x1b[I`
|
||||
// that the Ctrl button's own refocus emits (see the describe below).
|
||||
expect(terminalSource).toContain('!window.CodemanTerminalInput?.isTerminalFocusOrMouseReport(data)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanTerminalInput.isTerminalFocusOrMouseReport', () => {
|
||||
const isReport = loadTerminalInput().isTerminalFocusOrMouseReport;
|
||||
|
||||
it.each([
|
||||
['\x1b[I', 'focus in (DECSET 1004)'],
|
||||
['\x1b[O', 'focus out (DECSET 1004)'],
|
||||
['\x1b[<0;10;5M', 'SGR mouse press'],
|
||||
['\x1b[<0;10;5m', 'SGR mouse release'],
|
||||
['\x1b[<64;10;5M', 'SGR wheel up'],
|
||||
['\x1b[M !!', 'legacy X10 mouse'],
|
||||
])('classifies %j as terminal-generated (%s)', (data) => {
|
||||
expect(isReport(data)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['c', 'a typed character'],
|
||||
['\x03', 'a control byte'],
|
||||
['\r', 'Enter'],
|
||||
['\x1b', 'the Escape key'],
|
||||
['\x1b[A', 'an arrow key'],
|
||||
['\x1b[200~hi\x1b[201~', 'a bracketed paste'],
|
||||
['\x1b[?1;2c', 'a DA reply'],
|
||||
['I', 'the letter I'],
|
||||
])('leaves %j alone (%s)', (data) => {
|
||||
expect(isReport(data)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-shot Ctrl vs terminal-generated reports', () => {
|
||||
// The onData gate, as terminal-ui.js writes it. The wiring test above pins
|
||||
// the real source; this proves the behavior the gate buys.
|
||||
function feed(bar: Bar, data: string): string {
|
||||
const isReport = loadTerminalInput().isTerminalFocusOrMouseReport;
|
||||
return bar.isCtrlArmed() && !isReport(data) ? bar.consumeCtrl(data) : data;
|
||||
}
|
||||
|
||||
function shellBar() {
|
||||
const loaded = loadBar('shell');
|
||||
loaded.bar.refreshForActiveSession();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
it('survives a tap once an app in the pane turns mouse reporting on', () => {
|
||||
const { bar } = shellBar();
|
||||
// The live case: a shell session keeps the narrow scrollback strip, so mouse
|
||||
// DECSETs reach the browser. Measured against a real shell with vim-style
|
||||
// tracking on, one tap on the terminal spent the armed modifier silently.
|
||||
bar.handleAction('ctrl');
|
||||
expect(feed(bar, '\x1b[<0;10;5M')).toBe('\x1b[<0;10;5M');
|
||||
expect(feed(bar, '\x1b[<0;10;5m')).toBe('\x1b[<0;10;5m');
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
|
||||
// ...so the character the user actually types is still the one modified.
|
||||
expect(feed(bar, 'd')).toBe('\x04');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a focus report, should one ever reach xterm', () => {
|
||||
// Defense in depth: FOCUS_ESCAPE_FILTER (session.ts) strips `\x1b[?1004h`
|
||||
// from every PTY read, so sendFocusMode never turns on today. If it did,
|
||||
// the bar's own post-key refocus would emit `\x1b[I` and eat the modifier
|
||||
// before the user typed a single character.
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(feed(bar, '\x1b[I')).toBe('\x1b[I');
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
expect(feed(bar, 'c')).toBe('\x03');
|
||||
});
|
||||
|
||||
it('still spends the modifier on a paste, which is real input', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(feed(bar, 'git status')).toBe('git status');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-shot Ctrl through the CJK input field', () => {
|
||||
// The CJK textarea swallows keystrokes before onData sees them, so the CJK
|
||||
// send path needs the modifier applied too. These drive the REAL
|
||||
// _handleCjkInput against the REAL bar, both loaded into one vm scope.
|
||||
function cjkApp() {
|
||||
const { CodemanApp, bar } = loadTerminalModule();
|
||||
bar.clearCtrl();
|
||||
const app = Object.create(CodemanApp.prototype) as {
|
||||
activeSessionId: string;
|
||||
_sendInputAsync: ReturnType<typeof vi.fn>;
|
||||
_handleCjkInput(text: string): void;
|
||||
};
|
||||
app.activeSessionId = 'cjk-session';
|
||||
app._sendInputAsync = vi.fn();
|
||||
return { app, bar };
|
||||
}
|
||||
|
||||
it('sends the control byte for a character typed into the CJK field', () => {
|
||||
const { app, bar } = cjkApp();
|
||||
bar.toggleCtrl();
|
||||
app._handleCjkInput('c');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '\x03');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves ordinary CJK input untouched when nothing is armed', () => {
|
||||
const { app } = cjkApp();
|
||||
app._handleCjkInput('你好');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '你好');
|
||||
});
|
||||
|
||||
it('spends the modifier on a committed IME word instead of stranding it', () => {
|
||||
// The gap this closes: with the field focused the modifier could neither
|
||||
// fire nor be spent, so it survived to bite a later innocent keystroke.
|
||||
const { app, bar } = cjkApp();
|
||||
bar.toggleCtrl();
|
||||
app._handleCjkInput('你好');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '你好');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('spends the modifier on Enter, like every other non-character key', () => {
|
||||
const { app, bar } = cjkApp();
|
||||
bar.toggleCtrl();
|
||||
app._handleCjkInput('\r');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '\r');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the input, and does not spend the modifier, with no active session', () => {
|
||||
const { app, bar } = cjkApp();
|
||||
(app as unknown as { activeSessionId: string | null }).activeSessionId = null;
|
||||
bar.toggleCtrl();
|
||||
app._handleCjkInput('c');
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('armed styling survives the light-skin overrides', () => {
|
||||
const mobileCss = readFileSync(resolve('src/web/public/mobile.css'), 'utf8');
|
||||
|
||||
it('excludes .armed from the light-skin .accessory-btn repaint', () => {
|
||||
// That selector is (0,3,1): `:is()` takes the specificity of its most
|
||||
// specific argument and the list holds `.btn-toolbar.btn-shell`. It
|
||||
// therefore OUTRANKS the (0,3,0) armed rules in both stylesheets, and a
|
||||
// bare `.accessory-btn` there paints the armed modifier back to a resting
|
||||
// button on all four light skins (measured across every skin at 390px).
|
||||
const lightSkinRule = mobileCss
|
||||
.split('\n')
|
||||
.find((line) => line.includes('[data-skin="paper-gray"]') && line.includes('.btn-voice-mobile,'));
|
||||
|
||||
expect(lightSkinRule).toBeDefined();
|
||||
expect(lightSkinRule).toContain('.accessory-btn:not(.armed)');
|
||||
});
|
||||
|
||||
it('keeps an armed rule in both stylesheets', () => {
|
||||
// mobile.css hardcodes the phone palette, styles.css carries the
|
||||
// skin-aware one for everything wider.
|
||||
expect(mobileCss).toContain('.accessory-btn.accessory-btn-ctrl.armed');
|
||||
expect(readFileSync(resolve('src/web/public/styles.css'), 'utf8')).toContain(
|
||||
'.accessory-btn.accessory-btn-ctrl.armed'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1048,6 +1048,183 @@ describe('Virtual Keyboard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shell keyboard bar + one-shot Ctrl (issue #262) ───────────────────
|
||||
//
|
||||
// The bar swaps layouts per session mode, and Ctrl is a one-shot modifier
|
||||
// applied to the next character typed on the SYSTEM keyboard, which on a
|
||||
// phone reaches the app as xterm onData text, not a key event. These drive
|
||||
// the real xterm instance with page.keyboard.type() and assert on what would
|
||||
// go out on the wire (_sendInputAsync), not on DOM state alone.
|
||||
|
||||
describe('Shell keyboard bar', () => {
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ context, page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'));
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
/** Point the app at a fake session of `mode` and re-resolve the bar. */
|
||||
async function activateSession(mode: string, id = 'kb-shell-1'): Promise<void> {
|
||||
await page.evaluate(`(function (id, mode) {
|
||||
app.sessions.set(id, { id, name: id, status: 'idle', mode, workingDir: '/tmp' });
|
||||
app.activeSessionId = id;
|
||||
KeyboardAccessoryBar.show();
|
||||
KeyboardAccessoryBar.refreshForActiveSession();
|
||||
})('${id}', '${mode}')`);
|
||||
}
|
||||
|
||||
/** Capture what the terminal would send, while typing on the real keyboard. */
|
||||
async function typeAndCapture(text: string): Promise<string[]> {
|
||||
await page.evaluate(`(function () {
|
||||
window.__sent = [];
|
||||
if (!app.__origSend) app.__origSend = app._sendInputAsync;
|
||||
app._sendInputAsync = function (sessionId, input) { window.__sent.push(input); };
|
||||
app.terminal.focus();
|
||||
})()`);
|
||||
await page.keyboard.type(text);
|
||||
await page.waitForTimeout(200);
|
||||
const sent = (await page.evaluate(`window.__sent`)) as string[];
|
||||
await page.evaluate(`(function () { app._sendInputAsync = app.__origSend; })()`);
|
||||
return sent;
|
||||
}
|
||||
|
||||
async function tapCtrl(): Promise<void> {
|
||||
await page.evaluate(`document.querySelector('.keyboard-accessory-bar [data-action="ctrl"]').click()`);
|
||||
}
|
||||
|
||||
it('shows the terminal bar for shell sessions', async () => {
|
||||
await activateSession('shell');
|
||||
const actions = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
|
||||
(button) => (button as HTMLElement).dataset.action
|
||||
)
|
||||
);
|
||||
expect(actions).toEqual([
|
||||
'ctrl',
|
||||
'esc',
|
||||
'tab',
|
||||
'scroll-up',
|
||||
'scroll-down',
|
||||
'arrow-left',
|
||||
'arrow-right',
|
||||
'paste',
|
||||
'dismiss',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the command bar for agent sessions', async () => {
|
||||
await activateSession('claude', 'kb-agent-1');
|
||||
const actions = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
|
||||
(button) => (button as HTMLElement).dataset.action
|
||||
)
|
||||
);
|
||||
expect(actions).toContain('init');
|
||||
expect(actions).not.toContain('ctrl');
|
||||
});
|
||||
|
||||
it('sends Ctrl+C for the next typed character and disarms', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
|
||||
|
||||
expect(await typeAndCapture('c')).toEqual(['\x03']);
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
// The very next keystroke is a literal c again.
|
||||
expect(await typeAndCapture('c')).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('sends Ctrl+D for the next typed character', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
expect(await typeAndCapture('d')).toEqual(['\x04']);
|
||||
});
|
||||
|
||||
it('survives a terminal tap while the pane has mouse reporting on', async () => {
|
||||
// A shell session keeps the narrow scrollback strip, so mouse DECSETs
|
||||
// reach the browser: run vim or htop and xterm starts reporting taps
|
||||
// through onData as \x1b[<0;31;23M. Those arrive on the same channel as
|
||||
// typed characters, so a hook that treats every chunk as "the next
|
||||
// keystroke" spends Ctrl on a tap and the button looks dead. Verified
|
||||
// against a real shell session before this guard existed.
|
||||
await activateSession('shell');
|
||||
await page.evaluate(`app.terminal.write('\\x1b[?1000h\\x1b[?1006h')`);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await tapCtrl();
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
|
||||
|
||||
const box = await page.locator('.xterm-screen').first().boundingBox();
|
||||
await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
|
||||
expect(await typeAndCapture('c')).toEqual(['\x03']);
|
||||
|
||||
await page.evaluate(`app.terminal.write('\\x1b[?1000l\\x1b[?1006l')`);
|
||||
});
|
||||
|
||||
it('cancels on a second tap of Ctrl', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
await tapCtrl();
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
expect(await typeAndCapture('c')).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('shows the armed state and keeps the terminal focused', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
const state = await page.evaluate(() => {
|
||||
const button = document.querySelector('.keyboard-accessory-bar [data-action="ctrl"]') as HTMLElement;
|
||||
const style = getComputedStyle(button);
|
||||
return {
|
||||
armed: button.classList.contains('armed'),
|
||||
pressed: button.getAttribute('aria-pressed'),
|
||||
background: style.backgroundColor,
|
||||
focusedTerminal: document.activeElement === (app.terminal as { textarea: Element }).textarea,
|
||||
};
|
||||
});
|
||||
expect(state.armed).toBe(true);
|
||||
expect(state.pressed).toBe('true');
|
||||
// Armed styling must actually land (three-class rule beating the skin
|
||||
// overrides): an invisible modifier is worse than none.
|
||||
expect(state.background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(state.focusedTerminal).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the armed modifier when switching sessions', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
|
||||
|
||||
await activateSession('shell', 'kb-shell-2');
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
expect(await typeAndCapture('c')).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('drops the armed modifier when the keyboard is dismissed', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
await page.evaluate(`KeyboardAccessoryBar.hide()`);
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
});
|
||||
|
||||
it('spends the modifier on another accessory key instead of the next keystroke', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
await page.evaluate(`document.querySelector('.keyboard-accessory-bar [data-action="esc"]').click()`);
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
expect(await typeAndCapture('c')).toEqual(['c']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-device keyboard behavior ────────────────────────────────────
|
||||
|
||||
describe('Cross-device keyboard behavior', () => {
|
||||
|
||||
@@ -73,4 +73,36 @@ describe('read my mind phone key + alternates (static guards)', () => {
|
||||
expect(ui).toContain('readMyMindAlternates');
|
||||
expect(ui).toMatch(/\.textContent = suggestion\.prompt/);
|
||||
});
|
||||
|
||||
// Phase 3 part 2: the Rethink steer note (docs/readmymind-plan.md phase 3).
|
||||
it('wires the rethink steer note end to end: field, payload, phase visibility, reset', () => {
|
||||
// The field lives in the modal, capped to the schema's 2000-char limit,
|
||||
// and Enter in it triggers a rethink (mirroring the prompt field's
|
||||
// Enter-to-send).
|
||||
expect(html).toMatch(/id="readMyMindSteer"[^>]*maxlength="2000"/);
|
||||
expect(html).toMatch(/id="readMyMindSteer"[^>]*onkeydown="[^"]*rethinkReadMyMind\(\)"/);
|
||||
// Predict sends the trimmed note as `steer`, bounded to the schema cap.
|
||||
expect(ui).toMatch(/body\.steer = steer\.slice\(0, 2000\)/);
|
||||
// The row hides ONLY during loading: Rethink is live in both the ready
|
||||
// and the empty-result phases, so the note must be reachable in both.
|
||||
expect(ui).toMatch(/steerRow\.style\.display = phase === 'loading' \? 'none' : ''/);
|
||||
// A fresh open resets the note along with the rethink memory.
|
||||
expect(ui).toMatch(/steer\.value = ''/);
|
||||
});
|
||||
|
||||
it('styles the footer with btn-toolbar (bare "btn btn-*" matches no CSS in this codebase)', () => {
|
||||
const modal = html.slice(html.indexOf('id="readMyMindModal"'), html.indexOf('id="approvalsDrawer"'));
|
||||
// The unstyled classes the footer originally shipped with must not return.
|
||||
expect(modal).not.toMatch(/class="btn /);
|
||||
expect(modal.match(/class="btn-toolbar/g)?.length).toBe(4);
|
||||
expect(modal).toMatch(/class="btn-toolbar btn-primary"[^>]*sendReadMyMind\(true\)/);
|
||||
// btn-toolbar is display:flex (block-level): without the desktop footer
|
||||
// row rule the four buttons would stack vertically.
|
||||
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \{[^}]*display: flex/);
|
||||
// The skin block's bare .btn-toolbar (0,2,1) greys out .btn-primary
|
||||
// (0,2,0), so Send's accent must be re-asserted at higher specificity.
|
||||
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar\.btn-primary \{[^}]*var\(--accent\)/);
|
||||
// The phone block sizes the same class for finger targets.
|
||||
expect(phoneBlock).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user