Compare commits

..
Author SHA1 Message Date
Codeman maintainer 12a5f5919e chore: version packages 2026-08-05 22:36:51 +02:00
Codeman maintainer ecd3f3f32a harden(history): exclude automated transcripts by SDK shape, not by "not cli"
#215 filters non-interactive transcripts out of Past Sessions with
`entrypoint !== 'cli'`. That is an allowlist on a value, and the check
hides rows, so it fails CLOSED on anything Claude Code has not shipped
yet: the day it stamps a new interactive entrypoint (a rename, or a
second interactive host), no transcript matches 'cli' any more and the
entire Past Sessions list goes blank with nothing in the UI explaining
why.

Invert it to a blocklist on the SDK shape (`sdk`, `sdk-cli`, `sdk-py`).
An automated entrypoint we do not recognize yet now costs a few noisy
rows, which is the annoyance the filter set out to fix, rather than a
dead feature. Matches the fail-open reasoning #215 already applied to a
MISSING entrypoint field; only the unknown-VALUE case was inverted.

Test fails against the pre-fix line and passes after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:47:06 +02:00
Ark0N c19d884a51 Merge pull request #215 from timkjr/fix/past-sessions-history-quality
fix(history): three Past Sessions data-quality bugs (automated-session noise, cross-contaminated previews, blank restart-heavy rows)
2026-08-05 21:44:47 +02:00
Ark0N 22e77a1827 Merge pull request #214 from timkjr/fix/mobile-overview-run-gating
fix(mobile): gate the phone overview's run picker on CLI availability
2026-08-05 21:44:42 +02:00
Ark0N b641560040 Merge pull request #203 from shenlvkang-collab/contrib/claude-viewer-session-pin
fix(web): pin the Claude response viewer to the pane's own conversation
2026-08-05 21:44:37 +02:00
timkjr 8300c15cbd fix(history): entrypoint detection was first-field-wins, plus a two-tier head read
extractTranscriptEntrypoint returned the FIRST entrypoint-bearing message's
value instead of scanning for any 'cli' occurrence, so a transcript that
started under an older Claude Code build (no entrypoint field) and later
picked up a non-'cli' entrypoint on some later message was wrongly excluded
from history — the opposite of the fail-open behavior the function's own
comment claimed. Now returns 'cli' the moment any scanned message carries it,
and only falls back to a non-cli value when nothing else qualifies. Head/tail
entrypoints are merged the same way (either side being 'cli' wins).

Also restructures scanProjectDir's head read into two tiers: try 16KB first
and escalate to 128KB only when that wasn't enough, instead of reading 128KB
for every file unconditionally. Measured against a real ~/.claude/projects
tree, the unconditional-128KB version roughly quadrupled scan cost to fix a
problem only a minority of files actually have; the two-tier version cuts
bytes read by ~36% and wall time by ~17% while producing identical output.
Also fixes a fallback regression where a failed head read (e.g. EMFILE) on a
file at or under the head buffer size no longer got a shot at the tail-read
fallback, silently dropping the session from history.
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 09f5f28017 docs(test): correct an overclaiming comment in the tail-fallback regression test
The comment implied the fallback could be "silently skipped" by the
stale hardcoded threshold, which isn't actually true -- the old
smaller numbers were always more eager to trigger the fallback, never
less (same correction as the commit this test belongs to). What the
test actually protects against is the fallback logic itself breaking
(e.g. a copy-paste slip dropping the check entirely), not the exact
threshold value. Reworded to say that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 251706be3b harden: scope entrypoint detection to message lines, add fallback coverage
Two follow-ups from reviewing the entrypoint-filter and head-buffer
fixes before submitting them upstream:

1. extractTranscriptEntrypoint() scanned any line containing the
   substring "entrypoint", not specifically the first "type":"user"/
   "type":"assistant" message line (unlike its sibling
   extractFirstUserPrompt, which does scope to type). A transcript
   that started under an older Claude Code version (no entrypoint
   field) and got resumed under a newer one mid-conversation could
   pick up the field from a much later message than the true first
   one, misattributing the session's origin. Scoped it to match.

2. Added a regression test proving the tail-read fallback still
   engages correctly when bookkeeping accumulation exceeds even the
   new 128KB head window, not just the 16KB it previously blanked at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 18b473f0e4 fix(history): raise the transcript head-read window to fit restart bookkeeping
Blank firstPrompt rows weren't all oversized messages -- traced one
directly: a session restarted many times (mux deaths, redeploys)
accumulates a batch of small bookkeeping lines (mode/permission-mode/
last-prompt/queue-operation, one batch per restart) ahead of the real
first message. With enough restarts these alone crossed the old 16KB
head-read window, so extraction found nothing even though the actual
first message was tiny (measured case: ~17.5KB of bookkeeping pushed a
189-byte real message just past the boundary).

Raise the head buffer from 16KB to 128KB (matching the existing
precedent at the codex-history head-read a few hundred lines up) and
fix three now-stale `> 16384`/`> 65536` fallback thresholds to
reference headBuf.length instead of hardcoded numbers, so the tail-read
fallbacks stay correctly scoped to "beyond what head already covered."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 a2aed38073 fix(unified-sessions): stop the firstPrompt workingDir backfill from cross-contaminating history rows
COD-140's backfill was meant to cover live/persisted rows whose Codeman
id doesn't match an on-disk transcript UUID, guessing from the newest
transcript in the same workingDir as a last resort. It was also firing
for pure history rows whose OWN transcript scan already ran (and
genuinely found nothing, e.g. an oversized first message) -- those got
silently backfilled with the newest OTHER session's opening line from
the same directory. Not a blank row, but actively wrong: old sessions
displayed a completely unrelated (often today's live) conversation's
first prompt as if it were their own.

Skip the workingDir guess for any item that already has its own
'history' source -- it already had a real, direct attempt. Rows with
no history source at all (their transcript isn't linked/scanned under
their own id yet) still get the guess, matching the original intent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 e888c65c52 fix(history): exclude non-interactive (SDK-driven) transcripts from Past Sessions
Automated tools (CI review bots, etc.) invoke Claude Code via the SDK
and write their transcripts into the same ~/.claude/projects tree as
real interactive sessions, but were never something a user can resume
into -- no PTY, no running process. Their one-shot review prompts also
embed the full diff inline as a single message, often exceeding the
16KB head / 32KB tail windows this scanner reads, so they cluttered
Past Sessions two ways: as blank rows when the huge message couldn't
be parsed, or as N identical "Review this change for security
vulnerabilities..." rows when it could.

Claude Code stamps `entrypoint` on its own message records ('cli' for
a real interactive session, e.g. 'sdk-py' for an SDK invocation).
Exclude any transcript whose entrypoint isn't 'cli' from the history
list entirely, checked last so it reuses whatever head/tail the prompt
extraction already read. Missing entrypoint (older transcripts) reads
as interactive -- fail open, matching every other gating check in this
codebase. Shared by /api/history/sessions and /api/sessions/unified,
since both call the same scanProjectDir().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 1ea39de650 fix(mobile): gate the phone overview's run picker on CLI availability
MOBILE_OVERVIEW_RUN_MODES / _buildMobileOverviewRunMenu is a separate,
hardcoded duplicate of the toolbar's #runModeMenu (mobile-overview.js
is a newer feature that mirrors the toolbar menu's look/behavior
rather than reusing its render), so it never picked up #201's
isCliAvailable() gating and offered every backend regardless of what
the server actually has installed.

Gate it the same way: skip an entry unless isCliAvailable(mode),
shell always exempt. Added functional + static regression tests
mirroring the toolbar menu's own test pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:15 -05:00
Codeman maintainer e2a644997e chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 09:01:45 +02:00
Ark0N cd5a101626 Merge pull request #213 from Ark0N/feat/file-viewer-edit-mode
File Viewer: edit mode for text files (edit + save in the viewer)
2026-08-05 09:00:22 +02:00
Codeman maintainer d9123de9eb feat(terminal): Ctrl+C copies the selection, interrupts when nothing is selected
Closes #211. Copying from the terminal only worked through the browser
context menu, because xterm turns Ctrl+C into 0x03 and cancels the keydown,
so the muscle-memory copy failed silently and read as "no copy-paste at all".

With a selection, Ctrl+C now copies it, toasts, clears the selection and
sends nothing to the PTY. With no selection it falls through unchanged, so
the interrupt is intact. Ctrl+Shift+C is an explicit copy chord that never
falls through: an explicit copy that interrupts a running agent because the
selection happened to be empty would be a footgun.

Three details that keep the interrupt safe:

- The decision lives in attachCustomKeyEventHandler (terminal-ui.js) and the
  no-selection path returns true WITHOUT preventDefault. xterm calls the
  custom handler before its own cancel(), so returning false alone does not
  cancel the event; the copy path therefore calls preventDefault explicitly,
  or the browser would run its native copy on top of ours.
- copy-selection is a full registry entry (rebindable and disableable in App
  Settings) whose action is deliberately absent from SHORTCUT_ACTIONS, the
  same trick command-palette uses: the generic capture loop preventDefaults
  every match it dispatches, which would cost the user the interrupt key.
- The gate is keydown-only, since the custom handler also runs for keypress
  and keyup.

Copy goes through _copyText (Clipboard API, then hidden-textarea +
execCommand) rather than raw navigator.clipboard, because install.sh's LAN
option serves plain HTTP where navigator.clipboard is undefined; the
fallback steals focus, so the terminal is refocused afterwards.

Tests: test/terminal-copy-selection.test.ts pins the gate and the
SHORTCUT_ACTIONS invariant; test/terminal-copy-shortcut.test.ts drives real
key presses in chromium and asserts on the clipboard plus the bytes xterm
emitted (browser-driven, so excluded from test:ci like the other Playwright
suites). Verified manually on an isolated beta instance before landing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:38:57 +02:00
shenlvkang-collab ab7a703e90 fix(web): keep the viewer's conversation anchor across a Codeman restart
start() reassigns _claudeSessionId to `resumeSessionId || id` on every launch,
including the path that re-attaches to a mux session that outlived the restart.
A pane whose CLI had moved on via /clear therefore came back pointing the
response viewer at its pre-/clear transcript, and because Session.lastSubmitAt
lived only in memory, the history correlation had nothing to correct it with
until the user happened to type again — observed as hours of the eye showing a
conversation the pane had long since left.

Persist lastSubmitAt in SessionState, restore it in restoreMuxSessions(), and
flush it when the viewer adopts (a /clear emits no completion event, which is
the trigger that would otherwise have persisted it). Recovered panes now
re-derive their live conversation on the viewer's first poll.

Restoring a stale anchor is safe: the resolver already refuses a candidate
transcript older than the one the pane is currently on, which is the shape of a
respawn into a fresh conversation.
2026-08-03 21:22:33 +08:00
shenlvkang-collabandClaude Opus 5 73315bc351 fix(web): pin the Claude response viewer to the pane's own conversation
The viewer re-derived a pane's live conversation from the newest
~/.claude/history.jsonl entry for the pane's cwd. A cwd is shared with every
other Codeman tab on it, with tabs long since closed, and with any plain
`claude` the user runs in their own terminal, so the eye followed whichever of
those was typed into last — and since the match was written back through
adoptClaudeSessionId(), the mispin stuck.

Credit a history entry to a pane only when it lands within 10s of that pane's
own Enter and no other pane on the same cwd submitted closer, reusing the
last-submit correlation the Codex locator already relies on. Submit tracking
moves from _codexLastSubmitAt to a mode-agnostic Session.lastSubmitAt. With no
correlated entry the pane keeps the id it has: a viewer one turn behind beats a
viewer showing someone else's conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:53:42 +08:00
30 changed files with 1661 additions and 106 deletions
+74
View File
@@ -1,5 +1,79 @@
# aicodeman
## 1.11.1
### Patch Changes
- fix(history): Past Sessions data quality, and gate the phone run picker on CLI availability
**Past Sessions data quality (#215).** Three bugs in the transcript scanner behind
the Cmd+K Session Manager and the phone overview's PAST SESSIONS list:
- Automated/SDK-driven transcripts (CI review bots and other tooling, which Claude
Code stamps with a non-`cli` `entrypoint`) were listed alongside real interactive
sessions even though they were never resumable. They are now excluded. Detection
scans every entrypoint-bearing message rather than stopping at the first, so a
transcript that began under an older Claude Code build and only later picked up a
non-`cli` entrypoint is no longer wrongly hidden.
- A resumed session could show a same-directory sibling's preview text as its own.
The `workingDir` backfill in `mergeUnifiedSessions()` now only ever applies to rows
that have no history entry of their own, so it can no longer overwrite a row's real
content with another conversation's.
- Sessions restarted many times accumulated enough bookkeeping lines to push the real
first prompt past the scanner's 16KB head-read window, leaving a blank row. The read
is now two-tier: 16KB first, escalating to 128KB only when that was not enough, which
is both correct and cheaper than reading 128KB unconditionally (measured on a real
transcript tree: 36% fewer bytes read, roughly 17.5% faster than the unconditional
version). Also restores the tail-read fallback for a file whose head read failed
outright (for example `EMFILE` while scanning hundreds of files), which had been
silently dropping the session from history.
Follow-up hardening on top of the above: the automated-transcript exclusion now
blocklists the SDK entrypoint shape (`sdk`, `sdk-cli`, `sdk-py`) instead of allowlisting
the exact value `cli`. Because the check hides rows, an allowlist failed closed on any
value Claude Code has not shipped yet: a future rename of the interactive entrypoint,
or a second interactive host, would have blanked the entire Past Sessions list with
nothing in the UI to explain it. An unrecognized automated entrypoint now costs a few
noisy rows instead, which is the annoyance this filter set out to fix rather than a
broken feature.
**Phone overview run picker (#214).** The "C" logo home screen's Run picker listed all
six backends regardless of what was installed, so tapping an uninstalled one produced a
failed launch instead of the entry simply not being offered. It is now gated on
`isCliAvailable()` exactly like the desktop toolbar's run-mode dropdown (shell exempt,
since it has no external CLI dependency and keeps the menu from ever being empty). The
picker is a hardcoded duplicate of the toolbar menu rather than a shared render, which
is why it never picked up the earlier gating work; a test now asserts that every mode
the picker offers is gated, so a newly added backend cannot silently drift again.
- 73315bc: fix(web): stop the Claude response viewer from following another session's conversation
The viewer re-derived a pane's live conversation by taking the newest
`~/.claude/history.jsonl` entry for the pane's cwd. A cwd is shared with every
other Codeman tab on it, with tabs long since closed, and with any plain
`claude` run in the user's own terminal, so the eye followed whichever of those
was typed into last — and the adoption was written back to the session, so the
mispin persisted. Entries are now credited to a pane only when they land within
10s of that pane's own Enter and no other pane on the cwd submitted closer, the
same last-submit correlation the Codex locator already uses.
That correlation also has to survive a restart. `start()` resets
`claudeSessionId` to the launch id even when re-attaching to a mux session whose
CLI has since moved on via `/clear`, so a recovered pane pointed the viewer at
its pre-`/clear` transcript — and with the anchor itself living only in memory,
nothing corrected it until the user happened to type again. `lastSubmitAt` is
now persisted in `SessionState` and restored on boot recovery, so the viewer
re-derives the live conversation on its first poll.
## 1.11.0
### Minor Changes
- Two user-facing features since 1.10.0.
**Terminal: Ctrl+C copies the selection, interrupts when nothing is selected** (#211). Copying from the terminal previously worked only through the browser context menu: xterm turns Ctrl+C into 0x03 and cancels the keydown, so the muscle-memory copy failed silently and read as "no copy-paste at all". With a selection, Ctrl+C now copies it, shows the "Copied to clipboard" toast, clears the selection and sends nothing to the PTY; with no selection it falls through unchanged, so the interrupt is intact. Ctrl+Shift+C is an explicit copy chord that never interrupts. The shortcut is a normal registry entry (`copy-selection`), so it can be rebound or disabled in App Settings, and disabling it restores plain always-interrupt Ctrl+C. Copy goes through the Clipboard API with a hidden-textarea fallback, so it also works on plain-HTTP LAN installs.
**File Viewer: edit mode for text files** (#212). The file-preview overlay can now edit workspace text files in place, phone-first: `GET /api/sessions/:id/file-content?edit=1` reads for edit without the 500-line preview truncation (saving a truncated buffer would silently delete the rest) and returns a sha256 hash plus the detected EOL; `PUT /api/sessions/:id/file-content` saves. Edit-in-place only: there is no O_CREAT anywhere in the handler, so "never create, never delete" is structural. Confinement inherits the read path (realpath plus workspace boundary, ownership scoping) and adds sensitive-path and attachment-guard blocklists, a `.git/` subtree deny, and an extension allowlist (`svg` and `env` deliberately excluded). Optimistic concurrency is by content hash, so a file changed on disk mid-edit returns 409 with an overwrite option rather than clobbering. Writes are atomic (`wx` temp, fchmod, fsync, rename) which closes the validate-then-write TOCTOU window and cannot follow a pre-existing symlink. Binary and latin-1 content are refused via a NUL sniff plus a UTF-8 round-trip compare, and EOL is re-applied server-side so a textarea's LF normalization cannot turn a two-line edit of a CRLF file into a whole-file diff.
## 1.10.0
### Minor Changes
+3 -3
View File
@@ -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.10.0 (must match `package.json`)
**Version**: 1.11.1 (must match `package.json`)
## Project Overview
@@ -238,7 +238,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
@@ -262,7 +262,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry.
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry.
### Security
+2
View File
@@ -651,6 +651,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl/Cmd+C` | Copy selection, or interrupt when nothing is selected |
| `Ctrl+Shift+C` | Copy selection (never interrupts) |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input |
+2
View File
@@ -641,6 +641,8 @@ sc -l # 列出会话
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
| `Alt/Option+1`–`Alt/Option+9` | 切换到第 N 个标签(按物理键位,macOS Option 布局也适用) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
| `Ctrl/Cmd+C` | 复制选中内容;未选中时中断代理 |
| `Ctrl+Shift+C` | 复制选中内容(永不中断) |
| `Ctrl/Cmd+L` | 清屏 |
| `Ctrl+Shift+R` | 恢复终端尺寸 |
| `Ctrl+Shift+V` | 切换语音输入 |
+1
View File
@@ -24,6 +24,7 @@ export default defineConfig({
'test/inline-rename.test.ts', // browser (Playwright)
'test/opencode-resize.test.ts', // browser (Playwright)
'test/webgl-fallback.test.ts', // browser (Playwright)
'test/terminal-copy-shortcut.test.ts', // browser (Playwright)
],
setupFiles: ['./test/setup.ts'],
fileParallelism: false,
+12
View File
@@ -151,6 +151,14 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
**Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key:
1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler *before* its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top).
2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide.
3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside.
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
### WebGL renderer toggle
**WebGL renderer toggle** (#140, `webglRendererEnabled`): per-device (`displayKeys` set, stripped from the server payload — NOT in `SettingsUpdateSchema`, which is `.strict()`). The GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads; it's cleared only by an explicit OFF→ON save transition or `?webgl=force` (`shouldSkipWebGL` in constants.js). `?nowebgl` still forces the DOM renderer per-load.
@@ -160,6 +168,10 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`.
⚠️ **A Claude pane's conversation is identified by the pane's own Enter, never by "newest entry for this cwd".** `~/.claude/history.jsonl` records every submitted prompt as `{project, sessionId, timestamp}`, and `/clear` moves the pane to a fresh `<uuid>.jsonl` that nothing on the PTY announces — so the viewer has to re-derive the live conversation. Keying that off `project` alone was the bug: a cwd is shared with every other Codeman tab on it, with tabs long since closed, and with any plain `claude` the user runs in their own terminal, so the eye followed whichever of those conversations was typed into last and showed a stranger's transcript. `resolveActiveClaudeSessionIdFromHistory()` instead credits an entry to a pane only when it lands within `CLAUDE_SUBMIT_MATCH_MS` of that pane's `Session.lastSubmitAt` **and** no other pane on the same cwd submitted closer — the same last-submit correlation the Codex locator uses. With no correlated entry the pane keeps the id it has: a viewer one turn behind beats a viewer showing someone else's conversation.
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected.
+303
View File
@@ -0,0 +1,303 @@
# Terminal smart copy (Ctrl+C) plan
Issue: [#211](https://github.com/Ark0N/Codeman/issues/211) "Terminal: Ctrl+C should copy when text is selected (interrupt otherwise)".
Origin: r/selfhosted feedback, "Biggest stumbling block is apparent lack of copy-paste in the terminal."
Status: **implemented and shipped** on 2026-08-05 (this document is kept as the rationale record). It was first served as an isolated beta over Tailscale for manual sign-off, then landed. Section 2 is the research that shaped the design, sections 4 to 6 describe what was built.
---
## 1. What the issue asks for
- Text selected in the terminal + `Ctrl+C` -> copy the selection, toast, clear the selection, do NOT send the byte to the PTY.
- No selection + `Ctrl+C` -> unchanged, the interrupt (`0x03`) reaches the PTY.
- `Ctrl+Shift+C` as an explicit copy chord.
- The selection check must run before the shortcut registry dispatch so a rebind cannot cost the user their interrupt key.
- Paste is out of scope (it already works via `Ctrl+V`, which terminal-ui.js routes to the image/text paste trap).
## 2. Verified current behavior
### 2.1 xterm cancels the Ctrl+C keydown, so no copy can happen
`src/web/public/vendor/xterm.min.js` (xterm 6.x), `_keyDown`:
```js
_keyDown(x){ if(this._keyDownHandled=!1, this._keyDownSeen=!0,
this._customKeyEventHandler && this._customKeyEventHandler(x)===!1) return !1;
... evaluateKeyboardEvent(...) ... this.cancel(x) ... }
```
Two consequences that shape the design:
1. The custom handler runs **first**, before xterm evaluates the key. Returning `false` exits before `cancel(x)`, so returning `false` does **not** call `preventDefault()` for us.
2. When the handler returns `true`, xterm turns Ctrl+C into `0x03` and cancels the event, which is why the browser's own copy command never runs.
Probe (headless chromium against an isolated server on port 3174, selection active, real focus on `.xterm-helper-textarea`, synthetic Ctrl+C keydown):
```json
{ "hasSelection": true, "defaultPrevented": true, "dataSeen": ["\"\\u0003\""],
"clipboardAfter": "SENTINEL-BEFORE", "stillHasSelection": false }
```
So today: interrupt byte sent, clipboard untouched, and xterm drops the selection anyway. The last point matters, "copy then clear the selection" is not a behavior change in how the selection feels, it is what already happens on any keypress.
### 2.2 Why right-click Copy works today
xterm registers a `copy` listener on its root element that substitutes the selection text:
```js
this._register(addDisposableListener(this.element,"copy",(k=>{ this.hasSelection() && copyHandler(k,this._selectionService) })))
```
Second probe (port 3175, real `page.keyboard.press('Control+c')`, custom handler patched to return `false` for Ctrl+C without `preventDefault`):
```json
{ "dataSeen": [], "copyEvents": ["xterm-element"],
"clipboardAfter": "native-copy-probe-line\n...", "stillHasSelection": true }
```
So a "return false and let the browser copy" implementation would also work in Chromium. It is rejected below (section 3.3) because it gives no toast, does not clear the selection, and leans on per-browser behavior of the copy command when the focused element is xterm's empty helper textarea.
### 2.3 The document-level capture handler will not interfere
`setupEventListeners()` in `src/web/public/app.js:989` runs on document capture, before xterm's textarea listener. Its registry loop skips any entry whose action is not in the local `SHORTCUT_ACTIONS` map:
```js
if (shortcut.disabled || !shortcut.action) continue;
const action = SHORTCUT_ACTIONS[shortcut.action];
if (!action) continue;
```
This is exactly how `command-palette` already behaves: it is a full registry entry (rebindable and disableable in App Settings) whose dispatch happens in a dedicated, focus-aware gate rather than the generic loop. The new copy entry follows that pattern, so the capture handler falls through untouched and the terminal handler owns the decision.
### 2.4 Registry matching rules that constrain the bindings
`matchesShortcutEvent()` (`app.js:4890`):
- Ctrl and Cmd are interchangeable as the primary modifier, so a `['ctrl']` binding also matches Cmd+C on macOS. That is fine here: with a selection it copies (same result the native macOS path gives today), without one it falls through.
- Every other modifier must be declared exactly: `if (mods.includes('shift') !== !!e.shiftKey) return false`. So `Ctrl+Shift+C` needs its own binding, a plain `ctrl+c` binding will never swallow it.
- `binding.code` wins when present, otherwise `binding.key` is compared case-insensitively.
### 2.5 Where selection is actually possible
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
### 2.6 Helpers that already exist and should be reused
| Need | Existing code |
| --- | --- |
| Clipboard write with an HTTP-safe fallback | `_copyText(text)` in `app.js:1887` (Clipboard API, then hidden textarea + `execCommand`) |
| Toast | `showToast(message, type)` in `panels-ui.js:4385` |
| Translated string | `'Copied to clipboard'` already in `i18n.js:453` |
| Focus-aware chord gate to copy the shape of | `shouldOpenCommandPaletteFromShortcut(e)` in `panels-ui.js:285` |
| Buffer-wide copy (currently unreferenced) | `copyTerminal()` in `terminal-ui.js:2615` |
`_copyText` matters more than it looks: `install.sh`'s LAN option serves plain HTTP, where `navigator.clipboard` is undefined. The issue's suggested `navigator.clipboard.writeText` alone would silently do nothing for those users, the `execCommand` fallback covers them.
## 3. Design
### 3.1 Behavior
| Chord | Selection present | No selection |
| --- | --- | --- |
| `Ctrl+C` (and Cmd+C, per registry equivalence) | copy, toast, clear selection, swallow the key | fall through, xterm sends `0x03` (interrupt) |
| `Ctrl+Shift+C` | copy, toast, clear selection, swallow the key | swallow, no-op (see 3.2) |
| Shortcut disabled in App Settings | never copies, `Ctrl+C` is always the interrupt | unchanged |
| Rebound to another chord | that chord copies when a selection exists | plain `Ctrl+C` is always the interrupt |
### 3.2 Why `Ctrl+Shift+C` with no selection is swallowed rather than forwarded
Today `Ctrl+Shift+C` produces `0x03` as well (the shift is irrelevant to the control byte), so forwarding would be "no regression". But once the chord is advertised as *the explicit copy key*, letting it interrupt a running agent when the selection happens to be empty is a footgun with no upside. Swallowing costs nothing: a user who wants to interrupt has `Ctrl+C` right there.
The rule in code is "no selection and the matched chord had Shift -> swallow", not a hardcoded key check, so it stays correct under rebinds.
### 3.3 Why an explicit clipboard write rather than falling through to the native copy
Probe 2 showed the native path works in Chromium, but the explicit write is chosen because it:
- gives the "Copied to clipboard" toast, which is the discoverability half of the issue,
- clears the selection so a second `Ctrl+C` interrupts (the smart-copy contract),
- works on plain-HTTP LAN installs through `_copyText`'s `execCommand` fallback,
- does not depend on how each browser treats a copy command issued while an empty textarea has focus.
### 3.4 Why no new app setting
Per-shortcut enable/disable and rebinding already exist in App Settings -> Shortcuts and are driven by the registry. A user who wants "Ctrl+C is always interrupt" unchecks one box. Adding a `terminalSmartCopy` setting would duplicate that and would drag in the per-device vs synced decision (`displayKeys` + `.strict()` `SettingsUpdateSchema`) for no gain.
## 4. Code changes, file by file
### 4.1 `src/web/public/app.js`, registry entry
Add to `DEFAULT_SHORTCUTS` (after the `clear-terminal` entry, ~line 351) so the Terminal group stays together:
```js
{
id: 'copy-selection',
group: 'Terminal',
label: 'Copy Selection',
bindings: [
{ modifiers: ['ctrl'], key: 'c' },
{ modifiers: ['ctrl', 'shift'], key: 'C' },
],
// Dispatched by shouldCopyTerminalSelectionFromShortcut() in terminal-ui.js,
// deliberately NOT in SHORTCUT_ACTIONS: the generic capture loop always
// preventDefaults, which would cost the user the interrupt key.
action: 'copyTerminalSelection',
},
```
Match on `key`, not `code`. xterm decides what byte to emit from the produced character, so intercepting the physical `KeyC` on a layout where it does not produce "c" would diverge from what xterm would have sent.
The `action` string is required for App Settings to render the row as configurable (`configurable = !!shortcut.action && Array.isArray(shortcut.bindings)`, `settings-ui.js:2624`). Do **not** add `copyTerminalSelection` to `SHORTCUT_ACTIONS`.
### 4.2 `src/web/public/terminal-ui.js`, the gate
New prototype method, modeled on `shouldOpenCommandPaletteFromShortcut`:
```js
shouldCopyTerminalSelectionFromShortcut(ev) {
if (!ev || ev.type !== 'keydown') return false; // the handler also runs for keypress/keyup
if (!ev.ctrlKey && !ev.metaKey && !ev.altKey) return false; // hot path: plain typing exits here
const registryAvailable =
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
const entry = registryAvailable
? this.getShortcutRegistry().find((s) => s.id === 'copy-selection')
: null;
if (entry) return !entry.disabled && this.matchesShortcutEvent(ev, entry);
return (ev.key || '').toLowerCase() === 'c' && !ev.altKey; // fallback for isolated harnesses
}
```
### 4.3 `src/web/public/terminal-ui.js`, the branch
Inside `attachCustomKeyEventHandler` (`terminal-ui.js:133`), after the command-palette gate and before the `Ctrl+V` branch:
```js
// Smart copy (#211): with a selection, Ctrl+C copies instead of sending ^C.
// With no selection it MUST fall through (return true, no preventDefault) or
// the interrupt key is lost. Ctrl+Shift+C is the explicit chord and never
// falls through: an "explicit copy" that interrupts the agent is a footgun.
if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
if (selection) {
ev.preventDefault();
void this.copyTerminalSelection(selection);
return false;
}
if (ev.shiftKey) {
ev.preventDefault();
return false;
}
return true;
}
```
`preventDefault()` is explicit because returning `false` alone does not cancel the event (section 2.1), and without it the browser would run its own copy on top of ours.
### 4.4 `src/web/public/terminal-ui.js`, the copy action
```js
async copyTerminalSelection(text) {
const selection = text ?? (this.terminal.hasSelection?.() ? this.terminal.getSelection() : '');
if (!selection) return false;
const ok = await this._copyText(selection);
if (ok) {
this.terminal.clearSelection?.();
this.showToast('Copied to clipboard', 'success');
} else {
this.showToast('Failed to copy', 'error');
}
// _copyText's execCommand fallback focuses a temp textarea; restore the
// terminal (this.terminal.focus is the CJK-aware router, not xterm's raw focus).
this.terminal.focus();
return ok;
}
```
The selection text is captured **before** the first `await`, and `navigator.clipboard.writeText` is reached in the same task as the keydown, so user activation still holds.
### 4.5 `src/web/public/i18n.js`
`'Copied to clipboard'` exists. Add `'Failed to copy': '复制失败'` (the error path is new to this surface).
### 4.6 Documentation
| File | Change |
| --- | --- |
| `README.md` shortcut table (~line 648) | `\| `Ctrl/Cmd+C` \| Copy selection (interrupts when nothing is selected) \|` and a `Ctrl+Shift+C` row |
| `src/web/public/index.html` help modal, Terminal section (~line 641) | `<div><kbd>Ctrl</kbd>+<kbd>C</kbd></div><div>Copy Selection / Interrupt</div>` plus the Ctrl+Shift+C row. Keep the existing negative assertion in `help-modal-shortcuts.test.ts` in mind (it forbids `Ctrl+K`, `C` is fine) |
| `CLAUDE.md` "Keyboard shortcuts" line | add `Ctrl+C` (copy selection, else interrupt) and `Ctrl+Shift+C` |
| `docs/architecture-invariants.md` -> "Command palette and shortcut registry" | append the invariant: the no-selection path must return `true` without `preventDefault`, the branch is keydown-only, and `copyTerminalSelection` must stay out of `SHORTCUT_ACTIONS` |
The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-driven and pick the entry up with no edit.
## 5. Edge cases and risks
| Case | Handling |
| --- | --- |
| Handler also fires for `keypress`/`keyup` | gated on `ev.type === 'keydown'`. xterm's `_keyPress` bails on ctrl combos anyway, so no stray byte |
| CJK IME composing | the existing `isComposing || keyCode === 229` guard is the first line of the handler and stays first |
| Local echo overlay has unsent `pendingText` | the copy branch returns before `onData`, so `pendingText`, flushed offsets and the durable input queue are untouched. The no-selection path is byte-identical to today, including the "control char flushes buffered text then sends `0x03`" logic at `terminal-ui.js:895` |
| Plain HTTP (LAN install) | `_copyText` falls back to `execCommand`, then focus is restored |
| Clipboard write rejected (permissions policy, no gesture) | error toast, right-click Copy still available |
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
## 6. Test plan
New file `test/terminal-copy-selection.test.ts` (node env, `vm` harness in the style of `test/command-palette-ui.test.ts`), covering `shouldCopyTerminalSelectionFromShortcut` in isolation:
1. Ctrl+C keydown -> true, keyup/keypress of the same chord -> false.
2. Ctrl+Shift+C -> true, plain `c` -> false, Ctrl+K -> false.
3. Registry entry `disabled: true` -> false for every chord.
4. Rebound entry (for example Alt+Y) -> true for the rebind, false for Ctrl+C.
5. Missing registry (harness without `getShortcutRegistry`) -> falls back to the `c` check.
Static assertions appended to `test/keyboard-shortcuts.test.ts` (this suite already pins the xterm-handler chokepoint):
6. `DEFAULT_SHORTCUTS` contains `id: 'copy-selection'` and `SHORTCUT_ACTIONS` does **not** contain `copyTerminalSelection` (the interrupt-safety invariant).
7. `terminal-ui.js` contains the `shouldCopyTerminalSelectionFromShortcut` branch and a `return true` no-selection fall-through.
8. README + help modal rows exist (mirrors the existing palette/Alt-nav doc assertions).
`test/help-modal-shortcuts.test.ts`: add `expectShortcut(helpModal, ['Ctrl', 'C'], 'Copy Selection')`.
New browser test `test/terminal-copy-shortcut.test.ts` (Playwright, port **3174**, free per a scan of `test/`), following `test/webgl-fallback.test.ts`: boot `WebServer`, grant `clipboard-read`/`clipboard-write`, `terminal.write()` a known line, `selectLines()`, real `page.keyboard.press('Control+c')`, then assert clipboard content, empty `onData` capture, cleared selection and the toast. Second case: no selection, assert `onData` saw `\u0003` and the clipboard is unchanged.
Per repo convention, browser suites are excluded from CI, so add the filename to the exclude list in `config/vitest.ci.config.ts` and run it locally.
Regression runs: `npm test -- test/keyboard-shortcuts.test.ts`, `test/help-modal-shortcuts.test.ts`, `test/command-palette-ui.test.ts`, `test/input-send-order.test.ts`, then `npm run test:ci`.
## 7. Manual verification before COM (CLAUDE.md rule)
Against a throwaway session on the live instance (`curl -sk https://localhost:3000/...`, never w1/w2/w3):
1. Select output with the mouse, press Ctrl+C, confirm the toast, paste elsewhere, confirm the agent did not stop.
2. Press Ctrl+C again with nothing selected, confirm the agent interrupts.
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select.
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
## 8. Out of scope, follow-ups worth filing separately
- **Teammate/subagent terminals** (`panels-ui.js:2268`) have the same blocked-copy problem. One `attachCustomKeyEventHandler` reusing `copyTerminalSelection` would fix them, but it touches a different surface and deserves its own change.
- **A mobile copy affordance.** Selection is disabled on touch, so phones still cannot copy terminal text. The unreferenced `copyTerminal()` (whole buffer) plus a keyboard-accessory "Copy" button would be the cheapest answer.
- **Right-click context menu** with Copy/Paste, better discoverability than any chord, but a bigger UI surface.
- **`copyTerminal()` cleanup**: it uses raw `navigator.clipboard` rather than `_copyText`, so it would fail on plain HTTP if ever wired up.
## 9. PR mechanics
- Branch off `master` (verify with `git branch --show-current`, the tree is shared), stage explicit paths only.
- Files touched: `src/web/public/app.js`, `src/web/public/terminal-ui.js`, `src/web/public/i18n.js`, `src/web/public/index.html`, `README.md`, `CLAUDE.md`, `docs/architecture-invariants.md`, `docs/terminal-copy-shortcut-plan.md`, three test files, `config/vitest.ci.config.ts`.
- `index.html`, `app.js` and `terminal-ui.js` are `.prettierignore`d hand-formatted assets, match the surrounding style by hand. `npm run check:public-assets` and `npm run check:frontend-syntax` are the guards.
- No changeset in this PR: a merged, unconsumed changeset turns the Release workflow red until the next COM, and the COM flow writes release notes covering everything since the last tag (current version is 1.10.0).
- Close #211 from the PR body.
Rough size: about 60 lines of product code, most of the work is the tests and the four documentation surfaces.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.10.0",
"version": "1.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.10.0",
"version": "1.11.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.10.0",
"version": "1.11.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+13 -2
View File
@@ -226,6 +226,16 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
// firstPrompt (so rows keyed to their own transcript are untouched).
//
// The workingDir guess is a last resort and MUST be skipped for any item that
// already has its own 'history' entry (step 1 above already gave it a real,
// direct scan of its own transcript). Without this guard, a history row whose
// OWN extraction genuinely failed (oversized first message, etc.) silently
// inherited the newest OTHER session's opening line from the same directory —
// not a blank, but actively wrong: old sessions displayed today's conversation
// as if it were their own. A row with no 'history' source at all (its
// transcript hasn't been linked/scanned under its own id yet) has no such
// direct attempt to prefer, so the guess remains a reasonable stand-in there.
const firstPromptByUuid = new Map<string, string>();
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
@@ -254,12 +264,13 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
}
}
for (const item of map.values()) {
const hasOwnHistoryEntry = item.sources.includes('history');
if (!item.firstPrompt) {
// never overwrite an existing non-empty prompt
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
if (byUuid) {
item.firstPrompt = byUuid;
} else if (item.workingDir) {
} else if (item.workingDir && !hasOwnHistoryEntry) {
const byDir = firstPromptByWorkingDir.get(item.workingDir);
if (byDir) item.firstPrompt = byDir.prompt;
}
@@ -268,7 +279,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
if (byUuid) {
item.lastPrompt = byUuid;
} else if (item.workingDir) {
} else if (item.workingDir && !hasOwnHistoryEntry) {
const byDir = lastPromptByWorkingDir.get(item.workingDir);
if (byDir) item.lastPrompt = byDir.prompt;
}
+24 -13
View File
@@ -509,6 +509,8 @@ export class Session extends EventEmitter {
tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
lastSubmitAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
remote?: SessionRemote;
/** Docker execution metadata for sessions launched inside a container via local tmux. */
@@ -535,6 +537,12 @@ export class Session extends EventEmitter {
this._lastActivityAt = this.createdAt;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = config.resumeSessionId || this.id;
// Restored from state.json on boot recovery. start() resets _claudeSessionId
// to the launch id even when re-attaching to a mux session whose CLI has
// moved on (a `/clear` before the restart), so this anchor is what lets the
// response viewer re-derive the live conversation without waiting for the
// user to type again.
this._lastSubmitAt = config.lastSubmitAt ?? 0;
this._mux = config.mux || null;
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
this._muxSession = config.muxSession || null;
@@ -1135,6 +1143,7 @@ export class Session extends EventEmitter {
// recovery can re-attach.
respawnBlocked: this._respawnBlocked || undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
@@ -2543,26 +2552,28 @@ export class Session extends EventEmitter {
* ```
*/
write(data: string): void {
this._trackCodexSubmit(data);
this._trackSubmit(data);
if (this.ptyProcess) {
this.ptyProcess.write(data);
}
}
// ── Codex thread tracking ─────────────────────────────────────────────
// When a codex pane last submitted a message (Enter). The response-viewer
// correlates this against ~/.codex/history.jsonl entry timestamps to find
// the thread the pane is ACTUALLY on — the only signal that survives
// /resume, /new and /fork typed inside the codex TUI itself.
private _codexLastSubmitAt = 0;
// ── Conversation tracking ─────────────────────────────────────────────
// When this pane last submitted a message (Enter). The response-viewer
// correlates this against the CLI's own history.jsonl entry timestamps to
// find the conversation the pane is ACTUALLY on — the only signal that
// survives /clear, /resume, /new and /fork typed inside the TUI itself,
// none of which announce themselves on the PTY's stdout.
private _lastSubmitAt = 0;
get codexLastSubmitAt(): number {
return this._codexLastSubmitAt;
/** Wall-clock ms of this pane's last Enter; 0 if it has never submitted. */
get lastSubmitAt(): number {
return this._lastSubmitAt;
}
private _trackCodexSubmit(data: string): void {
if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) {
this._codexLastSubmitAt = Date.now();
private _trackSubmit(data: string): void {
if (data.includes('\r') || data.includes('\n')) {
this._lastSubmitAt = Date.now();
}
}
@@ -2619,7 +2630,7 @@ export class Session extends EventEmitter {
* ```
*/
async writeViaMux(data: string): Promise<boolean> {
this._trackCodexSubmit(data);
this._trackSubmit(data);
if (this._mux && this._muxSession) {
return this._mux.sendInput(this.id, data);
}
+9
View File
@@ -480,6 +480,15 @@ export interface SessionState {
effort?: EffortLevel;
/** Sanitized per-session attachment history. */
attachmentHistory?: SessionAttachmentHistoryItem[];
/**
* Wall-clock ms of this pane's last Enter (Session.lastSubmitAt). Persisted
* because it is the response-viewer's only anchor for re-deriving the pane's
* live conversation after a Codeman restart: `start()` resets
* `claudeSessionId` to the launch id even when re-attaching to a mux session
* whose CLI has since moved on via `/clear`, and the correlation cannot run
* again until the pane's own Enter is known.
*/
lastSubmitAt?: number;
/**
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
+16
View File
@@ -349,6 +349,22 @@ const DEFAULT_SHORTCUTS = [
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
action: 'clearTerminal',
},
{
id: 'copy-selection',
group: 'Terminal',
label: 'Copy Selection',
// Bindings match on `key`, not `code`: xterm decides which byte to emit from the
// PRODUCED character, so intercepting a physical KeyC that doesn't produce "c"
// would diverge from the chord that actually sends ^C.
bindings: [
{ modifiers: ['ctrl'], key: 'c' },
{ modifiers: ['ctrl', 'shift'], key: 'C' },
],
// Dispatched by shouldCopyTerminalSelectionFromShortcut() in terminal-ui.js and
// deliberately absent from SHORTCUT_ACTIONS: the generic capture loop always
// preventDefaults on a match, which would cost the user the interrupt key.
action: 'copyTerminalSelection',
},
{
id: 'increase-font',
group: 'Terminal',
+1
View File
@@ -451,6 +451,7 @@
'Respawn Blocked': '重生已阻止',
'Task Complete': '任务完成',
'Copied to clipboard': '已复制到剪贴板',
'Failed to copy': '复制失败',
'Checking…': '正在检查…',
'Starting…': '正在启动…',
'Starting update…': '正在开始更新…',
+2
View File
@@ -646,6 +646,8 @@
<section class="shortcut-section">
<h4>Terminal</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>C</kbd></div><div>Copy Selection (interrupts when nothing is selected)</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>C</kbd></div><div>Copy Selection</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
+6
View File
@@ -486,6 +486,11 @@ Object.assign(CodemanApp.prototype, {
* The Run picker: the same backends as the toolbar's run-mode menu, plus saved
* web tabs. Deliberately no "Recent Sessions" block, unlike the toolbar menu:
* past conversations have their own section further down this screen.
*
* Gated the same way as the toolbar's #runModeMenu (isCliAvailable(), shell
* exempt) — this list is a separate, hardcoded duplicate of the toolbar's menu
* rather than a shared render, so it never picked up #201's gating and offered
* every backend regardless of what's actually installed.
*/
_buildMobileOverviewRunMenu() {
const menu = document.createElement('div');
@@ -493,6 +498,7 @@ Object.assign(CodemanApp.prototype, {
const current = this.runMode || 'claude';
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue;
const option = document.createElement('button');
option.type = 'button';
option.className = 'mobile-overview-run-option' + (entry.mode === current ? ' selected' : '');
+64
View File
@@ -158,6 +158,30 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
// ^C. With NO selection the branch must fall through (return true, and no
// preventDefault) or the interrupt key is lost, which is the whole reason
// the selection check runs before any registry dispatch. Ctrl+Shift+C is
// the explicit copy chord and never falls through: an "explicit copy" that
// interrupts a running agent because the selection happened to be empty is
// a footgun with no upside.
// NOTE: returning false does NOT cancel the event (xterm's _keyDown calls
// this handler before its own cancel()), so preventDefault is explicit:
// without it the browser runs its native copy on top of ours.
if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
if (selection) {
ev.preventDefault();
void this.copyTerminalSelection(selection);
return false;
}
if (ev.shiftKey) {
ev.preventDefault();
return false;
}
return true;
}
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
// Route through our paste trap which handles both images and text.
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
@@ -2612,6 +2636,46 @@ Object.assign(CodemanApp.prototype, {
// intentionally empty
},
// Registry-aware gate for the smart-copy chord (#211). Mirrors
// shouldOpenCommandPaletteFromShortcut(): honors a rebound or disabled
// 'copy-selection' entry, and falls back to the default chord when the
// registry isn't available (isolated test harnesses).
// Returning true only means "this chord asked to copy", the CALLER decides
// what happens when there is no selection, so the interrupt stays intact.
shouldCopyTerminalSelectionFromShortcut(ev) {
// The custom key handler also runs for keypress/keyup; only keydown decides.
if (!ev || ev.type !== 'keydown') return false;
// Hot path: every dispatchable chord needs Ctrl/Cmd/Alt, so plain typing
// exits before any registry work.
if (!ev.ctrlKey && !ev.metaKey && !ev.altKey) return false;
const registryAvailable =
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
const entry = registryAvailable ? this.getShortcutRegistry().find((s) => s.id === 'copy-selection') : null;
if (entry) return !entry.disabled && this.matchesShortcutEvent(ev, entry);
return !ev.altKey && (ev.key || '').toLowerCase() === 'c';
},
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
// option serves plain HTTP, where navigator.clipboard is undefined.
async copyTerminalSelection(text) {
const selection = text ?? (this.terminal.hasSelection?.() ? this.terminal.getSelection() : '');
if (!selection) return false;
const ok = await this._copyText(selection);
if (ok) {
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
// drops the selection on any keypress, so this matches existing feel).
this.terminal.clearSelection?.();
this.showToast('Copied to clipboard', 'success');
} else {
this.showToast('Failed to copy', 'error');
}
// The execCommand fallback focuses a temp textarea, so hand focus back. This
// is the CJK-aware focus router, not xterm's raw focus().
this.terminal.focus();
return ok;
},
async copyTerminal() {
try {
const buffer = this.terminal.buffer.active;
+219 -81
View File
@@ -965,80 +965,97 @@ export function registerSessionRoutes(
// ========== Get Last Response (from transcript JSONL) ==========
// Resolves the most recent Claude conversation id for a session's cwd by
// tailing ~/.claude/history.jsonl. After `/clear`, Claude Code keeps writing
// to a new <uuid>.jsonl; history.jsonl is the only source-of-truth update
// that does not rely on project-local hooks (we intentionally don't install
// hooks in arbitrary user repos, see the POST /api/sessions comment).
// How far apart a ~/.claude/history.jsonl entry and a pane's Enter may be and
// still be the same submission. Claude appends to history as it accepts the
// prompt, so the true gap is milliseconds — this is slack for a loaded box,
// not a search radius.
const CLAUDE_SUBMIT_MATCH_MS = 10_000;
// history.jsonl grows forever; only the tail can hold entries near a submit.
const CLAUDE_HISTORY_TAIL_BYTES = 256 * 1024;
// Resolves the Claude conversation id THIS pane is currently on by matching
// ~/.claude/history.jsonl (which logs every submitted prompt as
// {project, sessionId, timestamp}) against the pane's last Enter. After
// `/clear` Claude keeps writing to a new <uuid>.jsonl, and history.jsonl is
// the only source-of-truth update that does not rely on project-local hooks
// (we intentionally don't install hooks in arbitrary user repos, see the
// POST /api/sessions comment).
//
// Entries from OTHER Codeman sessions in the same cwd are filtered out by
// their known claudeSessionIds so concurrent tabs don't shadow each other,
// as long as each has had its id resolved at least once.
// The pane's own Enter is what makes an entry OURS. `project` alone is not:
// a cwd is shared by every other Codeman tab on it, by tabs long since
// closed, and by any plain `claude` the user runs in their own terminal —
// adopting the newest entry for the cwd pinned the viewer to whichever of
// those conversations was typed in last, so the eye showed a stranger's
// transcript. With no correlated entry we keep the id we have; a viewer one
// turn behind beats a viewer showing someone else's conversation.
const claudeHistoryPinCache = new LRUMap<string, { submitAt: number; claudeSessionId: string }>({ maxSize: 1024 });
async function resolveActiveClaudeSessionIdFromHistory(
session: Session,
projectsDir: string
): Promise<string | null> {
const historyPath = join(homedir(), '.claude', 'history.jsonl');
const submitAt = session.lastSubmitAt;
if (!submitAt) return null; // never typed through Codeman — nothing to credit
const cached = claudeHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
// Ids another live pane is already pinned to can never be ours, and every
// pane sharing this cwd competes for the entry we are about to claim —
// including non-Claude panes, since a shell pane can run `claude` too.
const otherClaudeIds = new Set<string>();
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.workingDir === session.workingDir && s.claudeSessionId) {
otherClaudeIds.add(s.claudeSessionId);
}
if (s.id === session.id || s.workingDir !== session.workingDir) continue;
if (s.claudeSessionId) otherClaudeIds.add(s.claudeSessionId);
if (s.lastSubmitAt) otherSubmits.push(s.lastSubmitAt);
}
let candidateSid: string | null = null;
try {
const content = await fs.readFile(historyPath, 'utf8');
const lines = content.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (!line) continue;
try {
const entry = JSON.parse(line) as { project?: string; sessionId?: string };
if (
entry.project === session.workingDir &&
typeof entry.sessionId === 'string' &&
!otherClaudeIds.has(entry.sessionId)
) {
candidateSid = entry.sessionId;
break;
}
} catch {
// Skip unparseable lines
}
}
} catch {
return null;
}
if (!candidateSid || candidateSid === session.id) return candidateSid;
const historyPath = join(homedir(), '.claude', 'history.jsonl');
const stat = await fs.stat(historyPath).catch(() => null);
if (!stat || stat.size === 0) return null;
const tail = await readFileTail(historyPath, Buffer.alloc(CLAUDE_HISTORY_TAIL_BYTES), stat.size);
if (!tail) return null;
// Safety: only adopt if the candidate's jsonl is more recently written
// than our initial conversation's jsonl. Blocks stale ids inherited from
// a prior Codeman session that happened to share this cwd.
try {
const projectDirs = await fs.readdir(projectsDir);
let best: { sessionId: string; dist: number } | undefined;
for (const line of tail.split('\n')) {
if (!line) continue;
let entry: { project?: string; sessionId?: string; timestamp?: number };
try {
entry = JSON.parse(line) as typeof entry;
} catch {
continue; // the first tail line is usually cut mid-JSON
}
const { sessionId, timestamp } = entry;
if (entry.project !== session.workingDir) continue;
if (typeof sessionId !== 'string' || !sessionId) continue;
if (typeof timestamp !== 'number') continue;
if (otherClaudeIds.has(sessionId)) continue;
const dist = Math.abs(timestamp - submitAt);
if (dist > CLAUDE_SUBMIT_MATCH_MS) continue;
if (otherSubmits.some((other) => Math.abs(timestamp - other) < dist)) continue; // another pane is closer
if (!best || dist <= best.dist) best = { sessionId, dist }; // ties: the newer entry wins
}
if (!best) return null;
// Sanity: the conversation we switch to must exist on disk and must not be
// staler than the one we are leaving. A `/clear` successor never is.
const currentSessionId = session.claudeSessionId || session.id;
if (best.sessionId !== currentSessionId) {
const projectDirs = await fs.readdir(projectsDir).catch(() => null);
if (!projectDirs) return null;
let candidateMtime = 0;
let initialMtime = 0;
let currentMtime = 0;
for (const projDir of projectDirs) {
try {
const cs = await fs.stat(join(projectsDir, projDir, `${candidateSid}.jsonl`));
if (cs.mtimeMs > candidateMtime) candidateMtime = cs.mtimeMs;
} catch {
/* not in this dir */
}
try {
const is = await fs.stat(join(projectsDir, projDir, `${session.id}.jsonl`));
if (is.mtimeMs > initialMtime) initialMtime = is.mtimeMs;
} catch {
/* not in this dir */
}
const candidateStat = await fs.stat(join(projectsDir, projDir, `${best.sessionId}.jsonl`)).catch(() => null);
if (candidateStat && candidateStat.mtimeMs > candidateMtime) candidateMtime = candidateStat.mtimeMs;
const currentStat = await fs.stat(join(projectsDir, projDir, `${currentSessionId}.jsonl`)).catch(() => null);
if (currentStat && currentStat.mtimeMs > currentMtime) currentMtime = currentStat.mtimeMs;
}
if (candidateMtime === 0) return null;
if (initialMtime > 0 && candidateMtime <= initialMtime) return null;
} catch {
return null;
if (candidateMtime === 0) return null; // transcript not written yet — retry next poll
if (currentMtime > 0 && candidateMtime < currentMtime) return null;
}
return candidateSid;
claudeHistoryPinCache.set(session.id, { submitAt, claudeSessionId: best.sessionId });
return best.sessionId;
}
interface ClaudeResponseMessage {
@@ -1236,6 +1253,11 @@ export function registerSessionRoutes(
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
if (activeId && activeId !== session.claudeSessionId) {
session.adoptClaudeSessionId(activeId);
// Flush the Enter that vouched for this adoption to state.json. A `/clear`
// emits no completion event, so without this the anchor could still be
// unpersisted when the server restarts — and recovery would fall back to
// the launch conversation.
ctx.persistSessionState(session);
// Docker sessions: keep the case's resume seed following the live conversation.
if (session.docker) {
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
@@ -1309,7 +1331,7 @@ export function registerSessionRoutes(
return { cwd, originator };
}
// The pane's last Enter (Session.codexLastSubmitAt) correlated against
// The pane's last Enter (Session.lastSubmitAt) correlated against
// ~/.codex/history.jsonl, which logs every submitted user message as
// {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
// is the only signal that survives /resume, /new and /fork typed inside the
@@ -1318,10 +1340,10 @@ export function registerSessionRoutes(
// can't steal the attribution.
const codexHistoryPinCache = new LRUMap<string, { submitAt: number; threadId: string }>({ maxSize: 1024 });
async function resolveCodexThreadFromHistory(
session: { id: string; codexLastSubmitAt?: number },
session: { id: string; lastSubmitAt?: number },
codexHome: string
): Promise<string | null> {
const submitAt = session.codexLastSubmitAt || 0;
const submitAt = session.lastSubmitAt || 0;
if (!submitAt) return null;
const cached = codexHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.threadId;
@@ -1335,8 +1357,8 @@ export function registerSessionRoutes(
const WINDOW_MS = 15_000;
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) {
otherSubmits.push(s.codexLastSubmitAt);
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
otherSubmits.push(s.lastSubmitAt);
}
}
@@ -1379,7 +1401,7 @@ export function registerSessionRoutes(
async function findActiveCodexFile(session: {
id: string;
workingDir: string;
codexLastSubmitAt?: number;
lastSubmitAt?: number;
codexConfig?: { resumeSessionId?: string };
}): Promise<string | null> {
const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex');
@@ -2456,6 +2478,64 @@ export function registerSessionRoutes(
return undefined;
}
/**
* Is this `entrypoint` value an automated/SDK-driven invocation?
*
* ⚠️ Deliberately a BLOCKLIST on the SDK shape, not an allowlist on `'cli'`.
* The exclusion below hides rows, so an allowlist fails CLOSED on any value
* Claude Code has not shipped yet: the day it stamps a new interactive
* entrypoint (a rename, or a second interactive host), every transcript stops
* matching `'cli'` and the whole Past Sessions list goes blank with nothing in
* the UI to explain it. A blocklist fails OPEN instead — an automated
* entrypoint we do not recognize yet costs a few noisy rows, which is the
* annoyance this filter set out to fix rather than a broken feature.
*
* Observed values: `cli` (interactive), `sdk-cli` / `sdk-py` (automated).
*/
function isAutomatedEntrypoint(entrypoint: string): boolean {
return /^sdk(-|$)/.test(entrypoint);
}
/**
* The `entrypoint` field Claude Code stamps on its own message records:
* 'cli' for a real interactive session, something else (e.g. 'sdk-py') for
* an SDK/automated invocation. Used to exclude non-interactive transcripts
* (CI review bots, etc.) from the resumable history list — they were never
* something a user can resume into.
*
* Scans every `"type":"user"`/`"type":"assistant"` line with an entrypoint
* field — not just the first one — and returns 'cli' the moment ANY of them
* carries it. A transcript is excluded only when every entrypoint-bearing
* message says something else; "first field wins" would misattribute a
* transcript that started under an older Claude Code version (no entrypoint
* on its true first message) and later picked up a non-'cli' entrypoint on
* some later message, wrongly hiding a genuinely interactive session. This
* deliberately errs toward keeping a session visible: one real interactive
* message anywhere is enough. Returns undefined ("unknown", fail-open) only
* when nothing scanned carries the field at all.
*/
function extractTranscriptEntrypoint(text: string): string | undefined {
let start = 0;
let sawNonCli: string | undefined;
while (start < text.length) {
const end = text.indexOf('\n', start);
const line = end === -1 ? text.slice(start) : text.slice(start, end);
start = end === -1 ? text.length : end + 1;
if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
if (!line.includes('"entrypoint"')) continue;
try {
const entry = JSON.parse(line);
if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') {
if (entry.entrypoint === 'cli') return 'cli';
sawNonCli ??= entry.entrypoint;
}
} catch {
// Malformed/truncated line — skip
}
}
return sawNonCli;
}
/**
* Extract the text of the LAST user message from a JSONL transcript chunk
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
@@ -2668,7 +2748,7 @@ export function registerSessionRoutes(
return finalExists ? current : process.env.HOME || '/tmp';
}
/** Read the first 16KB of a file for content sniffing. */
/** Read the first `buf.length` bytes of a file for content sniffing. */
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
try {
const fd = await fs.open(path, 'r');
@@ -2711,7 +2791,12 @@ export function registerSessionRoutes(
// Scan a single project directory and return all valid history sessions in it.
// Reused by both the global overview and the single-folder drill-down.
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
async function scanProjectDir(
projPath: string,
projDir: string,
smallHeadBuf: Buffer,
headBuf: Buffer
): Promise<HistorySession[]> {
const out: HistorySession[] = [];
const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) return out;
@@ -2729,22 +2814,45 @@ export function registerSessionRoutes(
if (!fileStat) continue;
if (fileStat.size < 4000) continue;
let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf);
const hasConversation = (text: string) =>
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
// Two-tier head read: try the cheap smallHeadBuf (16KB) size first -- enough
// for the vast majority of transcripts -- and only escalate to the full
// headBuf (128KB) when that wasn't enough. Reading 128KB unconditionally for
// EVERY file in the directory roughly quadrupled the cost of a full scan
// (measured against a real ~/.claude/projects tree: ~4x both bytes read and
// wall time) to fix a problem only ~28% of files actually have. Escalating
// resolves the restart-bookkeeping case (the reason 128KB exists at all)
// without ever touching the tail-read fallback below for most of that 28%.
let head = await readFileHead(filePath, smallHeadBuf);
let foundContent = head ? hasConversation(head) : false;
let firstPrompt = head ? extractFirstUserPrompt(head) : undefined;
if ((!foundContent || !firstPrompt) && head !== null && fileStat.size > smallHeadBuf.length) {
const biggerHead = await readFileHead(filePath, headBuf);
if (biggerHead) {
head = biggerHead;
if (!foundContent) foundContent = hasConversation(head);
if (!firstPrompt) firstPrompt = extractFirstUserPrompt(head);
}
}
let tail: string | null = null;
if (!foundContent && fileStat.size > 16384) {
// `head === null` (a failed read -- e.g. EMFILE while scanning hundreds of
// files) must also get a shot at the tail, not just "file bigger than the
// head buffer". Losing this dropped the session from history entirely
// instead of giving it a second chance, for any file at or under the head
// buffer size whose head read happened to fail.
if (!foundContent && (head === null || fileStat.size > headBuf.length)) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) foundContent = hasConversation(tail);
}
if (!foundContent) continue;
if (head) firstPrompt = extractFirstUserPrompt(head);
if (!firstPrompt && fileStat.size > 65536) {
// firstPrompt was already attempted from head (both tiers) above; this is
// purely the tail fallback for whatever's left unresolved.
if (!firstPrompt && (head === null || fileStat.size > headBuf.length)) {
if (!tail) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
@@ -2754,15 +2862,37 @@ export function registerSessionRoutes(
// COD-145: last (most recent) user prompt lives near the END of the file, so
// prefer the tail. For large files where no tail was read yet, read one
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
// then contains the whole transcript — scan it for the last match instead.
if (!tail && fileStat.size > 65536) {
// (mirrors the firstPrompt > headBuf.length block). Small files fit in `head`,
// which then contains the whole transcript — scan it for the last match instead.
if (!tail && fileStat.size > headBuf.length) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
const lastPrompt =
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
// Automated/SDK-driven invocations (CI review bots, etc.) write transcripts
// into the same ~/.claude/projects tree as interactive sessions but were
// never something a user can resume into — no PTY, no running process, and
// their "conversation" is typically a single one-shot prompt (often with a
// full diff embedded, which is exactly why it dwarfs this scanner's read
// windows and shows up above as blank or as an identical boilerplate
// sentence across many rows). Checked last, so it reuses whatever `head`/
// `tail` the prompt extraction above already read rather than triggering
// an extra file read. Missing entrypoint (older transcripts) reads as
// interactive — fail open, matching every other gating check in this
// codebase.
//
// head and tail are checked independently and merged with "cli wins" (not
// a first-truthy-value `??` chain): a large file's head might land on a
// non-'cli' message while a real interactive message sits in the tail (or
// vice versa), and either one being 'cli' is enough to keep the session.
const headEntrypoint = head ? extractTranscriptEntrypoint(head) : undefined;
const tailEntrypoint = tail ? extractTranscriptEntrypoint(tail) : undefined;
const entrypoint =
headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint);
if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue;
out.push({
sessionId,
workingDir,
@@ -2779,7 +2909,13 @@ export function registerSessionRoutes(
app.get('/api/history/sessions', async (req) => {
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const headBuf = Buffer.alloc(16384);
// scanProjectDir tries smallHeadBuf (16KB, the original size) first for every
// file and only escalates to headBuf (128KB) when that wasn't enough — see the
// comment at the escalation site in scanProjectDir for why unconditional 128KB
// reads were too expensive to keep. 128KB matches the existing precedent
// elsewhere in this file (line ~1431).
const smallHeadBuf = Buffer.alloc(16384);
const headBuf = Buffer.alloc(131072);
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
// must only see history whose decoded workingDir is inside their own case space.
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
@@ -2797,7 +2933,7 @@ export function registerSessionRoutes(
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
const projPath = join(projectsDir, query.projectKey);
let all = await scanProjectDir(projPath, query.projectKey, headBuf);
let all = await scanProjectDir(projPath, query.projectKey, smallHeadBuf, headBuf);
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
@@ -2810,7 +2946,7 @@ export function registerSessionRoutes(
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir);
const list = await scanProjectDir(projPath, projDir, headBuf);
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
results.push(...list);
}
} catch {
@@ -2886,11 +3022,13 @@ export function registerSessionRoutes(
const history: HistoryInput[] = [];
try {
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const headBuf = Buffer.alloc(16384);
// See the sibling allocation above for why there are two sizes.
const smallHeadBuf = Buffer.alloc(16384);
const headBuf = Buffer.alloc(131072);
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir);
const list = await scanProjectDir(projPath, projDir, headBuf);
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
for (const h of list) {
history.push({
sessionId: h.sessionId,
+4
View File
@@ -2508,6 +2508,10 @@ export class WebServer extends EventEmitter {
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though
// the re-attached CLI is on a post-`/clear` one.
lastSubmitAt: savedState?.lastSubmitAt,
// Remote SSH metadata must round-trip on recovery: without it the
// attach cwd falls back to the (nonexistent-locally) remote path and
// respawn rebuilds a LOCAL command, breaking the pane and silently
+2
View File
@@ -52,6 +52,8 @@ describe('help modal shortcuts', () => {
});
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
expectShortcut(helpModal, ['Ctrl', 'C'], 'Copy Selection');
expectShortcut(helpModal, ['Ctrl', 'Shift', 'C'], 'Copy Selection');
expectShortcut(helpModal, ['Ctrl', 'L'], 'Clear Terminal');
expectShortcut(helpModal, ['Ctrl', '+'], 'Increase Font');
expectShortcut(helpModal, ['Ctrl', '-'], 'Decrease Font');
+18
View File
@@ -58,4 +58,22 @@ describe('keyboard shortcuts', () => {
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
});
it('keeps the interrupt when Ctrl+C copies a selection (#211)', () => {
// The xterm handler owns this decision, and the no-selection path must fall
// through with NO preventDefault so xterm still evaluates Ctrl+C into 0x03.
expect(terminalUiSource).toContain('this.shouldCopyTerminalSelectionFromShortcut?.(ev)');
expect(terminalUiSource).toMatch(
/const selection = this\.terminal\.hasSelection\?\.\(\) \? this\.terminal\.getSelection\(\) : '';/
);
expect(terminalUiSource).toContain('void this.copyTerminalSelection(selection);');
expect(appSource).toContain("id: 'copy-selection'");
});
it('documents the terminal copy shortcut in help and README', () => {
expect(helpHtml).toContain('<kbd>Ctrl</kbd>+<kbd>C</kbd>');
expect(helpHtml).toContain('<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>C</kbd>');
expect(readme).toContain('`Ctrl/Cmd+C`');
expect(readme).toContain('`Ctrl+Shift+C`');
});
});
+66 -1
View File
@@ -12,13 +12,34 @@ import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
/** Minimal fake DOM node — enough surface for mobile-overview.js's programmatic builders. */
function fakeElement(): any {
const el: any = {
className: '',
type: '',
dataset: {},
style: {},
children: [] as any[],
setAttribute() {},
appendChild(child: any) {
el.children.push(child);
return child;
},
};
return el;
}
function loadOverviewApp(overrides: Record<string, any> = {}) {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window: {},
document: { getElementById: () => null },
document: {
getElementById: () => null,
createElement: () => fakeElement(),
createElementNS: () => fakeElement(),
},
MobileDetection: { getDeviceType: () => 'mobile' },
});
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
@@ -272,3 +293,47 @@ describe('mobile overview wiring', () => {
expect(hardcoded).toEqual([]);
});
});
describe('mobile overview run picker (CLI availability gating)', () => {
function modeButtons(menu: any): string[] {
return menu.children.filter((c: any) => c.dataset.moAction === 'run-mode').map((c: any) => c.dataset.moMode);
}
// #201 gated the toolbar's #runModeMenu on isCliAvailable(); this phone-only
// picker (MOBILE_OVERVIEW_RUN_MODES / _buildMobileOverviewRunMenu) is a
// separate, hardcoded duplicate of that menu rather than a shared render, so
// it silently offered every backend regardless of what the server reported.
it('hides run modes the server reports as unavailable, keeps shell always', () => {
const app = loadOverviewApp({
runMode: 'claude',
isCliAvailable: (tool: string) => tool === 'claude',
});
const menu = app._buildMobileOverviewRunMenu();
expect(modeButtons(menu)).toEqual(['claude', 'shell']);
});
it('shows every mode when every CLI is available', () => {
const app = loadOverviewApp({
runMode: 'claude',
isCliAvailable: () => true,
});
const menu = app._buildMobileOverviewRunMenu();
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']);
});
it('gates every mode the picker actually offers', () => {
// Catches a new backend being added to MOBILE_OVERVIEW_RUN_MODES without
// being gated — the same class of bug that let this list drift from the
// toolbar menu's gating in the first place.
const src = readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8');
const modesBlock = src.slice(
src.indexOf('const MOBILE_OVERVIEW_RUN_MODES'),
src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2
);
const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]);
expect(offered).toContain('antigravity');
const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {'));
const gate = fn.slice(0, fn.indexOf('const header'));
expect(gate).toContain('isCliAvailable');
});
});
+2
View File
@@ -19,6 +19,8 @@ export class MockSession extends EventEmitter {
ralphTracker: null = null;
writeBuffer: string[] = [];
terminalBuffer: string = '';
/** Mirrors Session.lastSubmitAt — the response viewer credits history entries by it. */
lastSubmitAt: number = 0;
private _muxName: string | null = null;
@@ -8,7 +8,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
@@ -176,3 +176,131 @@ describe('GET /api/sessions/:id/last-response (claude)', () => {
]);
});
});
/**
* Which conversation a pane is on is decided by the pane's own Enter, not by
* "newest entry for this cwd" — a cwd is shared with every other tab on it,
* with tabs long since closed, and with any plain `claude` the user runs in
* their own terminal.
*/
describe('GET /api/sessions/:id/last-response (claude conversation pinning)', () => {
let harness: LocalHarness;
let testHome: string;
let previousHome: string | undefined;
const WORKDIR = '/workspace';
const NOW = 1_770_000_000_000;
beforeEach(async () => {
testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-pin-'));
previousHome = process.env.HOME;
process.env.HOME = testHome;
harness = await createEnvelopeHarness();
});
afterEach(async () => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
rmSync(testHome, { recursive: true, force: true });
await harness.app.close();
});
/** A transcript whose only assistant turn is `text`, stamped at `mtimeMs`. */
function writeTranscript(conversationId: string, text: string, mtimeMs: number): void {
const projectDir = join(testHome, '.claude', 'projects', '-workspace');
mkdirSync(projectDir, { recursive: true });
const path = join(projectDir, `${conversationId}.jsonl`);
writeFileSync(
path,
JSON.stringify({
type: 'assistant',
timestamp: new Date(mtimeMs).toISOString(),
message: { content: [{ type: 'text', text }] },
})
);
utimesSync(path, mtimeMs / 1000, mtimeMs / 1000);
}
function writeHistory(entries: Array<{ sessionId: string; timestamp: number; project?: string }>): void {
const claudeDir = join(testHome, '.claude');
mkdirSync(claudeDir, { recursive: true });
writeFileSync(
join(claudeDir, 'history.jsonl'),
entries
.map((entry) => JSON.stringify({ display: 'prompt', project: entry.project ?? WORKDIR, ...entry }))
.join('\n')
);
}
/** Replaces the pre-seeded mock session with a Claude pane in WORKDIR. */
function addPane(id: string, conversationId: string, lastSubmitAt: number) {
const base = harness.ctx._session;
const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & {
claudeSessionId: string;
lastSubmitAt: number;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
Object.assign(pane, base, { id, mode: 'claude', workingDir: WORKDIR, docker: undefined });
pane.claudeSessionId = conversationId;
pane.lastSubmitAt = lastSubmitAt;
pane.adoptClaudeSessionId = vi.fn((newId: string) => {
pane.claudeSessionId = newId;
});
harness.ctx.sessions.set(id, pane);
return pane;
}
async function getLastResponse(sessionId: string) {
const response = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/last-response` });
return JSON.parse(response.body).data as { text: string };
}
it('does not adopt a conversation from another claude process sharing the cwd', async () => {
// The pane typed hours ago; a `claude` running in the user's own terminal
// is the newest thing in this cwd. Before this fix the viewer followed it.
const pane = addPane('pane-1', 'pane-conversation', NOW - 6 * 3600_000);
writeTranscript('pane-conversation', 'my own answer', NOW - 6 * 3600_000);
writeTranscript('someone-elses-conversation', 'a stranger answer', NOW);
writeHistory([{ sessionId: 'someone-elses-conversation', timestamp: NOW }]);
expect(await getLastResponse('pane-1')).toEqual({ text: 'my own answer', timestamp: expect.any(String) });
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
});
it('follows /clear onto the new conversation the pane submitted into', async () => {
const pane = addPane('pane-1', 'before-clear', NOW);
writeTranscript('before-clear', 'answer before clear', NOW - 60_000);
writeTranscript('after-clear', 'answer after clear', NOW + 500);
writeHistory([{ sessionId: 'after-clear', timestamp: NOW + 120 }]);
expect(await getLastResponse('pane-1')).toEqual({ text: 'answer after clear', timestamp: expect.any(String) });
expect(pane.adoptClaudeSessionId).toHaveBeenCalledWith('after-clear');
});
it('stays put when the pane has never submitted through Codeman', async () => {
const pane = addPane('pane-1', 'pane-conversation', 0);
writeTranscript('pane-conversation', 'my own answer', NOW - 60_000);
writeTranscript('unrelated-conversation', 'a stranger answer', NOW);
writeHistory([{ sessionId: 'unrelated-conversation', timestamp: NOW }]);
expect(await getLastResponse('pane-1')).toEqual({ text: 'my own answer', timestamp: expect.any(String) });
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
});
it('credits a shared-cwd entry to the pane whose Enter is closest to it', async () => {
const near = addPane('pane-near', 'near-conversation', NOW);
const far = addPane('pane-far', 'far-conversation', NOW - 4_000);
writeTranscript('near-conversation', 'near answer', NOW - 60_000);
writeTranscript('far-conversation', 'far answer', NOW - 60_000);
writeTranscript('fresh-conversation', 'the freshly cleared answer', NOW + 500);
writeHistory([{ sessionId: 'fresh-conversation', timestamp: NOW + 100 }]);
// Both panes are inside the match window; only the closest may claim it.
expect(await getLastResponse('pane-far')).toEqual({ text: 'far answer', timestamp: expect.any(String) });
expect(far.adoptClaudeSessionId).not.toHaveBeenCalled();
expect(await getLastResponse('pane-near')).toEqual({
text: 'the freshly cleared answer',
timestamp: expect.any(String),
});
expect(near.adoptClaudeSessionId).toHaveBeenCalledWith('fresh-conversation');
});
});
@@ -104,7 +104,7 @@ describe('GET /api/sessions/:id/last-response (codex)', () => {
let codexHome: string;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any; // MockSession, loosened for codex-only fields (codexConfig, codexLastSubmitAt)
let session: any; // MockSession, loosened for codex-only fields (codexConfig, lastSubmitAt)
let workdir: string;
/** Write a rollout under CODEX_HOME/sessions/<date>/ with a controlled mtime. */
@@ -230,7 +230,7 @@ describe('GET /api/sessions/:id/last-response (codex)', () => {
it('history.jsonl pin (pane last-submit correlation) outranks the originator match', async () => {
const submitAtSec = BASE_MTIME + 500;
session.codexLastSubmitAt = submitAtSec * 1000;
session.lastSubmitAt = submitAtSec * 1000;
writeHistory([{ session_id: UUID_B, ts: submitAtSec }]);
// Originator-stamped rollout exists and is NEWER, but the pane /resume'd onto
+247
View File
@@ -1350,6 +1350,253 @@ describe('session-routes', () => {
expect(row.workingDir).toBe(dotDir);
expect(row.workingDir).not.toContain('//');
});
it('excludes non-interactive (SDK-driven) transcripts from the history list', async () => {
// CI review bots and other automated tools write transcripts into the same
// ~/.claude/projects tree as interactive sessions (entrypoint "sdk-py" etc.)
// but were never something a user can resume into — no PTY, no running
// process. They cluttered Past Sessions as blank rows or identical
// boilerplate ("Review this change for security vulnerabilities...").
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-test');
await mkdir(projPath, { recursive: true });
const cliId = '33333333-3333-3333-3333-333333333333';
const sdkId = '44444444-4444-4444-4444-444444444444';
const noEntrypointId = '55555555-5555-5555-5555-555555555555';
const cliLine =
JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real question' } }) +
'\n';
const sdkLine =
JSON.stringify({
type: 'user',
entrypoint: 'sdk-py',
message: { role: 'user', content: 'Review this change for security vulnerabilities.' },
}) + '\n';
// Older transcripts predate the entrypoint field entirely — must still show.
const noEntrypointLine =
JSON.stringify({ type: 'user', message: { role: 'user', content: 'a pre-entrypoint session' } }) + '\n';
await writeFile(join(projPath, `${cliId}.jsonl`), cliLine + '#'.repeat(4200 - cliLine.length));
await writeFile(join(projPath, `${sdkId}.jsonl`), sdkLine + '#'.repeat(4200 - sdkLine.length));
await writeFile(
join(projPath, `${noEntrypointId}.jsonl`),
noEntrypointLine + '#'.repeat(4200 - noEntrypointLine.length)
);
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain(cliId);
expect(ids).toContain(noEntrypointId);
expect(ids).not.toContain(sdkId);
});
it('ignores a bookkeeping line that happens to mention "entrypoint" outside a real message record', async () => {
// Scanning must anchor on "type":"user"/"assistant" lines specifically,
// not any line that happens to contain the substring "entrypoint".
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-bookkeeping-test');
await mkdir(projPath, { recursive: true });
const sessionId = '77777777-7777-7777-7777-777777777777';
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', entrypoint: 'sdk-py' }) + '\n';
const realLine =
JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real message' } }) +
'\n';
// scanProjectDir skips files under 4000 bytes.
const body = bookkeepingLine + realLine;
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-bookkeeping-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain(sessionId);
});
it('shows a session with ANY interactive (cli) message, even if an earlier message was automated', async () => {
// "First field wins" would have misattributed this: an old transcript
// whose true first message predates the entrypoint field, later resumed
// under something automated (entrypoint: 'sdk-py' on message 2), then
// continued interactively by a real person (entrypoint: 'cli' on message
// 3). Stopping at the first entrypoint-bearing line found ('sdk-py')
// would wrongly exclude a session a human genuinely used. One real
// interactive message anywhere is enough to keep it visible.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-any-cli-test');
await mkdir(projPath, { recursive: true });
const sessionId = '99999999-9999-9999-9999-999999999999';
const firstLine =
JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-entrypoint-field message' } }) + '\n';
const automatedLine =
JSON.stringify({
type: 'user',
entrypoint: 'sdk-py',
message: { role: 'user', content: 'an automated follow-up' },
}) + '\n';
const interactiveLine =
JSON.stringify({
type: 'user',
entrypoint: 'cli',
message: { role: 'user', content: 'a real person continued this' },
}) + '\n';
const body = firstLine + automatedLine + interactiveLine;
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-any-cli-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain(sessionId);
});
it('still excludes a session where every entrypoint-bearing message is automated', async () => {
// Mirror of the previous test with no 'cli' message anywhere — proves the
// "any cli wins" fix isn't just failing open unconditionally.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-all-automated-test');
await mkdir(projPath, { recursive: true });
const sessionId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const firstLine =
JSON.stringify({
type: 'user',
entrypoint: 'sdk-py',
message: { role: 'user', content: 'Review this change for security vulnerabilities.' },
}) + '\n';
const secondLine =
JSON.stringify({
type: 'assistant',
entrypoint: 'sdk-py',
message: { role: 'assistant', content: [{ type: 'text', text: 'Looking at the diff...' }] },
}) + '\n';
const body = firstLine + secondLine;
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-all-automated-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).not.toContain(sessionId);
});
it('keeps a session whose entrypoint is an unrecognized non-SDK value (fail open)', async () => {
// The exclusion is a blocklist on the SDK shape, NOT an allowlist on 'cli'.
// An allowlist fails CLOSED on any value Claude Code has not shipped yet:
// the day it stamps a new interactive entrypoint, nothing matches 'cli' and
// the entire Past Sessions list silently goes blank. Excluding only what we
// positively recognize as automated fails open instead — a few noisy rows,
// not a dead feature.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-unknown-test');
await mkdir(projPath, { recursive: true });
const sessionId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const line =
JSON.stringify({
type: 'user',
entrypoint: 'cli-next',
message: { role: 'user', content: 'a question from a future interactive host' },
}) + '\n';
await writeFile(join(projPath, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length));
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-unknown-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain(sessionId);
});
it('finds the real first prompt past a large run of pre-message bookkeeping lines', async () => {
// A session restarted many times over a long conversation accumulates a batch
// of small bookkeeping lines (mode/permission-mode/last-prompt/queue-operation)
// per restart, ahead of the real first message. With enough restarts these can
// push the genuine first prompt past a 16KB head-read window even though the
// message itself is tiny — the row showed up blank despite having real content.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-bookkeeping-test');
await mkdir(projPath, { recursive: true });
const sessionId = '66666666-6666-6666-6666-666666666666';
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n';
// > 16KB (the old head-read size) but well under 128KB (the new one).
const prefix = bookkeepingLine.repeat(Math.ceil(20000 / bookkeepingLine.length));
const realLine =
JSON.stringify({
type: 'user',
entrypoint: 'cli',
message: { role: 'user', content: 'the real first message' },
}) + '\n';
expect(prefix.length).toBeGreaterThan(16384);
await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine);
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-bookkeeping-test',
});
expect(res.statusCode).toBe(200);
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
expect(row).toBeDefined();
expect(row.firstPrompt).toBe('the real first message');
});
it('still falls back to the tail read when bookkeeping alone exceeds the new 128KB head window', async () => {
// Raising the head buffer to 128KB helps most restart-heavy sessions, but an
// even more extreme case (many more restarts) can still exceed it. This
// proves the tail-read fallback itself is intact after the threshold
// rewrite (`fileStat.size > headBuf.length` replacing the old hardcoded
// 16384/65536) — the fallback's own logic, not the exact threshold value,
// is what could have silently broken (e.g. a copy-paste slip that dropped
// the `> headBuf.length` check entirely). The real message sits near the
// end of the file, well inside the 32KB tail window, so a working fallback
// finds it; a broken one leaves the row blank exactly like the bug this
// whole fix addresses.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-tail-fallback-test');
await mkdir(projPath, { recursive: true });
const sessionId = '88888888-8888-8888-8888-888888888888';
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n';
// Comfortably past the new 128KB head window (was 16KB), so the head read
// never reaches a single "type":"user"/"assistant"/"summary" line.
const prefix = bookkeepingLine.repeat(Math.ceil(140000 / bookkeepingLine.length));
const realLine =
JSON.stringify({
type: 'user',
entrypoint: 'cli',
message: { role: 'user', content: 'found via tail fallback' },
}) + '\n';
expect(prefix.length).toBeGreaterThan(131072);
await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine);
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-tail-fallback-test',
});
expect(res.statusCode).toBe(200);
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
expect(row).toBeDefined();
expect(row.firstPrompt).toBe('found via tail fallback');
});
});
// ========== POST /api/sessions (with resumeSessionId) ==========
@@ -270,6 +270,75 @@ describe('mergeUnifiedSessions', () => {
expect(live!.firstPrompt).toBeUndefined();
});
it('does NOT borrow a sibling transcript for a history-only row whose own extraction failed (no cross-contamination)', () => {
// A pure history row already got its own real scan (step 1 keys it under its
// OWN sessionId) — if that extraction genuinely failed (oversized first
// message, noise-filtered, etc.), the workingDir guess must not paper over
// it with an unrelated session's opening line. Regression: an old session
// in a shared workingDir was displaying TODAY's live session's firstPrompt
// as its own, because the guess didn't check whether this row already had
// its own (failed) attempt.
const merged = mergeUnifiedSessions({
history: [
// This session's own transcript scan found no usable prompt.
{
sessionId: 'old-uuid',
workingDir: '/shared',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: undefined,
},
// A much newer, unrelated session in the same directory.
{
sessionId: 'newer-uuid',
workingDir: '/shared',
sizeBytes: 6000,
lastModified: '2026-06-01T00:00:00.000Z',
firstPrompt: "today's real prompt",
},
],
});
const old = merged.find((m) => m.sessionId === 'old-uuid');
expect(old).toBeDefined();
expect(old!.firstPrompt).toBeUndefined();
});
it('leaves a RESUMED session blank rather than borrowing a sibling, once its own transcript is aliased in', () => {
// The exact scenario COD-140's own comment lists first: a live/persisted row
// whose claudeSessionId aliases to an on-disk transcript. Once that alias
// successfully folds the transcript's own (failed) extraction into this row
// (sources includes 'history'), it must NOT then fall through to the
// workingDir guess and borrow an unrelated sibling's prompt -- same bug as
// the plain history-only case above, but for the resumed-session path the
// backfill mechanism was actually built for.
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-resumed', status: 'working', claudeSessionId: 'resumed-uuid', workingDir: '/shared' }],
history: [
// The resumed session's OWN transcript -- aliased in via claudeSessionId,
// but its own extraction found nothing.
{
sessionId: 'resumed-uuid',
workingDir: '/shared',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: undefined,
},
// An unrelated, newer sibling in the same directory.
{
sessionId: 'sibling-uuid',
workingDir: '/shared',
sizeBytes: 6000,
lastModified: '2026-06-01T00:00:00.000Z',
firstPrompt: "unrelated sibling's prompt",
},
],
});
const resumed = merged.find((m) => m.sessionId === 'codeman-resumed');
expect(resumed).toBeDefined();
expect([...resumed!.sources].sort()).toEqual(['history', 'live']);
expect(resumed!.firstPrompt).toBeUndefined();
});
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
+55
View File
@@ -0,0 +1,55 @@
/**
* @fileoverview The pane's last-Enter timestamp must survive a Codeman restart.
*
* `start()` resets `claudeSessionId` to the launch id even when re-attaching to
* a mux session whose CLI has since moved on (a `/clear` before the restart), so
* `lastSubmitAt` is the response viewer's only anchor for re-deriving the live
* conversation. If it is not persisted, a recovered pane shows the pre-`/clear`
* transcript until the user happens to type again — hours, in practice.
*
* Port: N/A (no server needed)
*/
import { describe, it, expect } from 'vitest';
import { Session } from '../src/session.js';
describe('session submit anchor', () => {
it('records the pane Enter and carries it into persisted state', () => {
const session = new Session({ workingDir: '/tmp' });
expect(session.lastSubmitAt).toBe(0);
expect(session.toState().lastSubmitAt).toBeUndefined();
const before = Date.now();
session.write('hello\r');
const after = Date.now();
expect(session.lastSubmitAt).toBeGreaterThanOrEqual(before);
expect(session.lastSubmitAt).toBeLessThanOrEqual(after);
expect(session.toState().lastSubmitAt).toBe(session.lastSubmitAt);
});
it('leaves the anchor unset for keystrokes that never submit', () => {
const session = new Session({ workingDir: '/tmp' });
session.write('hello');
session.write('\x1b[A'); // arrow-up: history recall, not a submit
expect(session.lastSubmitAt).toBe(0);
expect(session.toState().lastSubmitAt).toBeUndefined();
});
it('restores the anchor from persisted state on boot recovery', () => {
const submitted = new Session({ workingDir: '/tmp' });
submitted.write('prompt\r');
const persisted = submitted.toState();
const recovered = new Session({ workingDir: '/tmp', lastSubmitAt: persisted.lastSubmitAt });
expect(recovered.lastSubmitAt).toBe(submitted.lastSubmitAt);
expect(recovered.toState().lastSubmitAt).toBe(submitted.lastSubmitAt);
});
it('starts a pane with no persisted anchor at zero rather than NaN', () => {
const recovered = new Session({ workingDir: '/tmp', lastSubmitAt: undefined });
expect(recovered.lastSubmitAt).toBe(0);
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Smart-copy chord gate (#211).
*
* `Ctrl+C` has to keep meaning "interrupt" whenever nothing is selected, so the
* decision is split in two: `shouldCopyTerminalSelectionFromShortcut()` only
* answers "did this chord ask to copy", and the caller in the xterm custom key
* handler decides what to do when there is no selection. These tests pin the
* gate itself (registry-aware, keydown-only) plus the static invariants that
* keep the generic capture loop from ever swallowing the interrupt.
*
* Strategy: run terminal-ui.js in a vm with a stub CodemanApp, the same harness
* shape test/command-palette-ui.test.ts uses for panels-ui.js. No DOM, no xterm.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
type Shortcut = {
id: string;
disabled?: boolean;
bindings?: Array<{ modifiers?: string[]; key?: string; code?: string }>;
};
function loadTerminalHarness(registry?: Shortcut[]) {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: () => null, querySelector: () => null },
console,
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
Object,
});
const terminalUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(terminalUi, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
if (registry) {
app.getShortcutRegistry = () => registry;
// Real implementation, copied by reference from app.js semantics: ctrl/meta are
// interchangeable, every other modifier must be declared by the binding.
app.matchesShortcutEvent = (e: any, shortcut: Shortcut) => {
if (!shortcut || !Array.isArray(shortcut.bindings)) return false;
return shortcut.bindings.some((binding) => {
const mods = binding.modifiers || [];
const wantsPrimary = mods.includes('ctrl') || mods.includes('meta');
if (wantsPrimary !== !!(e.ctrlKey || e.metaKey)) return false;
if (mods.includes('shift') !== !!e.shiftKey) return false;
if (mods.includes('alt') !== !!e.altKey) return false;
if (binding.code && e.code === binding.code) return true;
if (binding.key && typeof e.key === 'string' && e.key.toLowerCase() === binding.key.toLowerCase()) return true;
return false;
});
};
}
return app;
}
const DEFAULT_REGISTRY: Shortcut[] = [
{
id: 'copy-selection',
bindings: [
{ modifiers: ['ctrl'], key: 'c' },
{ modifiers: ['ctrl', 'shift'], key: 'C' },
],
},
];
function keydown(over: Record<string, unknown> = {}) {
return {
type: 'keydown',
key: 'c',
code: 'KeyC',
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
...over,
};
}
describe('terminal smart-copy gate', () => {
it('matches the default Ctrl+C and Ctrl+Shift+C chords', () => {
const app = loadTerminalHarness(DEFAULT_REGISTRY);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(true);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'C', shiftKey: true }))).toBe(true);
// Cmd+C on macOS: the registry treats ctrl/meta as interchangeable.
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false, metaKey: true }))).toBe(true);
});
it('ignores plain typing and unrelated chords', () => {
const app = loadTerminalHarness(DEFAULT_REGISTRY);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false }))).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'k', code: 'KeyK' }))).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'v', code: 'KeyV' }))).toBe(false);
});
it('only decides on keydown (the handler also runs for keypress and keyup)', () => {
const app = loadTerminalHarness(DEFAULT_REGISTRY);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ type: 'keypress' }))).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ type: 'keyup' }))).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(null)).toBe(false);
});
it('honors a disabled shortcut so Ctrl+C goes back to being the interrupt', () => {
const app = loadTerminalHarness([{ ...DEFAULT_REGISTRY[0], disabled: true }]);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'C', shiftKey: true }))).toBe(false);
});
it('honors a rebound chord and stops claiming the old one', () => {
const app = loadTerminalHarness([{ id: 'copy-selection', bindings: [{ modifiers: ['alt'], key: 'y' }] }]);
expect(
app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false, altKey: true, key: 'y', code: 'KeyY' }))
).toBe(true);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(false);
});
it('falls back to the default chord when no registry is available', () => {
const app = loadTerminalHarness(); // no getShortcutRegistry / matchesShortcutEvent
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(true);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'x', code: 'KeyX' }))).toBe(false);
});
});
describe('smart-copy wiring invariants', () => {
it('registers copy-selection in the shortcut registry', () => {
expect(APP_SOURCE).toContain("id: 'copy-selection'");
expect(APP_SOURCE).toContain("action: 'copyTerminalSelection'");
});
it('keeps copyTerminalSelection OUT of SHORTCUT_ACTIONS', () => {
// The generic capture loop preventDefaults on every match it dispatches. If
// the copy action were reachable from there, Ctrl+C would be swallowed with
// no selection and the user would lose the interrupt key.
const actionsBlock = APP_SOURCE.slice(
APP_SOURCE.indexOf('const SHORTCUT_ACTIONS = {'),
APP_SOURCE.indexOf('// Use capture to handle before terminal')
);
expect(actionsBlock.length).toBeGreaterThan(0);
expect(actionsBlock).not.toContain('copyTerminalSelection');
});
});
+166
View File
@@ -0,0 +1,166 @@
/**
* Smart copy in a real browser (#211).
*
* The gate itself is unit-tested in test/terminal-copy-selection.test.ts. What
* can only be proven in a browser is the half that decides whether the PTY sees
* an interrupt: xterm calls the custom key handler BEFORE its own cancel(), so
* returning false does not preventDefault, and a synthetic KeyboardEvent never
* triggers a browser default action. Both facts mean the copy/interrupt split
* has to be driven with real key presses.
*
* Assertions are on real state: what landed on the clipboard, and what xterm
* emitted through onData (the bytes that would reach the PTY).
*
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally: npm test -- test/terminal-copy-shortcut.test.ts
*
* Port: 3174 (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3174;
const BASE_URL = `http://localhost:${PORT}`;
describe('terminal Ctrl+C smart copy', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
page = await context.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
// The first write after load can be dropped while the app finishes wiring
// its render pipeline, so poll until one really lands in the buffer.
await page.waitForFunction(
async () => {
const term = (window as any).app.terminal;
await new Promise((r) => term.write('\r\nWARMUP\r\n', r));
const buf = term.buffer.active;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes('WARMUP')) return true;
}
return false;
},
null,
{ timeout: 20000, polling: 500 }
);
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Write a marker line, optionally select it, and reset the capture state. */
async function setup(line: string, select: boolean, overrides: Record<string, unknown> = {}) {
await page.evaluate(
async ({ line, select, overrides }) => {
const app = (window as any).app;
const term = app.terminal;
const settings = app.loadAppSettingsFromStorage();
settings.shortcutOverrides = overrides;
app.saveAppSettingsToStorage(settings);
(window as any).__data = [];
if (!(window as any).__dataHooked) {
term.onData((d: string) => (window as any).__data.push(d));
(window as any).__dataHooked = true;
}
await new Promise((r) => term.write('\r\n' + line + '\r\n', r));
term.clearSelection();
if (select) {
const buf = term.buffer.active;
let row = -1;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes(line)) row = i;
}
if (row === -1) throw new Error('marker line not found in buffer');
term.select(0, row, line.length);
if (!(term.getSelection() || '').trim()) throw new Error('selection is empty');
}
document.querySelector('.xterm-helper-textarea')!.dispatchEvent(new Event('focus'));
(document.querySelector('.xterm-helper-textarea') as HTMLElement).focus();
await navigator.clipboard.writeText('SENTINEL');
},
{ line, select, overrides }
);
}
async function outcome() {
await page.waitForTimeout(350);
return page.evaluate(async () => ({
data: (window as any).__data as string[],
clipboard: (await navigator.clipboard.readText()).trim(),
hasSelection: (window as any).app.terminal.hasSelection(),
}));
}
it('copies the selection and sends nothing to the PTY', async () => {
await setup('COPY-CASE-SELECTED', true);
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.clipboard).toBe('COPY-CASE-SELECTED');
expect(res.data).toEqual([]);
expect(res.hasSelection).toBe(false); // cleared, so a second Ctrl+C interrupts
});
it('still interrupts when nothing is selected', async () => {
await setup('COPY-CASE-UNSELECTED', false);
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
expect(res.clipboard).toBe('SENTINEL');
});
it('copies on the explicit Ctrl+Shift+C chord', async () => {
await setup('COPY-CASE-EXPLICIT', true);
await page.keyboard.press('Control+Shift+C');
const res = await outcome();
expect(res.clipboard).toBe('COPY-CASE-EXPLICIT');
expect(res.data).toEqual([]);
});
it('never interrupts on Ctrl+Shift+C with an empty selection', async () => {
await setup('COPY-CASE-EXPLICIT-EMPTY', false);
await page.keyboard.press('Control+Shift+C');
const res = await outcome();
expect(res.data).toEqual([]);
expect(res.clipboard).toBe('SENTINEL');
});
it('restores the plain interrupt when the shortcut is disabled', async () => {
await setup('COPY-CASE-DISABLED', true, { 'copy-selection': { disabled: true } });
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
expect(res.clipboard).toBe('SENTINEL');
});
it('follows a rebind, and Ctrl+C goes back to pure interrupt', async () => {
await setup('COPY-CASE-REBOUND', true, { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'y' }] } });
await page.keyboard.press('Alt+y');
const rebound = await outcome();
expect(rebound.clipboard).toBe('COPY-CASE-REBOUND');
expect(rebound.data).toEqual([]);
await setup('COPY-CASE-REBOUND-2', true, { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'y' }] } });
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
});
it('leaves Ctrl+V on the paste trap', async () => {
await setup('COPY-CASE-PASTE', false);
await page.keyboard.press('Control+v');
const res = await outcome();
expect(res.data.join('')).toContain('SENTINEL'); // pasted text, not ^V
expect(res.data.join('')).not.toContain('\x16');
});
});