mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(input): a composer's paste-then-Enter send is checked like any other prompt
Story Mode's prompt bar and the phone's Compose sheet send a prompt as two writes: one bracketed paste, then a bare Enter about 120 ms later. Neither is a `text\r` pair, so SubmitVerifier was never armed for them, and a send while Claude Code was still ignoring Enter (2.1.277+, the first 30 to 50 s after the composer paints) sat unsent. PastedPromptWatch pairs the two writes back up, in Session.write() (the WebSocket path) and writeViaMux() (the POST fallback's Enter), and arms the verifier with the pasted text. Nothing may come between the two, and a newer paste cancels a pending check, so Enter never sends the user's own paste. The composer match learns what a pasted prompt looks like (measured on 2.1.303): continuation lines count for a short first line, and Claude's `[Pasted text #1 +3 lines]` stand-in counts only when its line count is the prompt's own. A prompt under 24 characters must now match whole, so a short answer cannot read a dialog's highlighted `1. Yes` row as itself. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fc93343760
commit
a6abbaa673
5 files changed
+300
-19
No files matched your search
@@ -88,7 +88,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`, and by `PastedPromptWatch` for the browser composers' two-write send, one bracketed paste then a bare Enter, on the WebSocket and the POST fallback alike) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent (a prompt under 24 characters must match whole; Claude's `[Pasted text #1 +3 lines]` stand-in counts only when its line count is the prompt's own); an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write or a newer paste replaces or cancels the schedule. Typed keystrokes plus Enter arm nothing. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
* prompt with the same glyph higher up in the transcript, so only the last one says
|
||||
* whether the text was taken.
|
||||
*
|
||||
* The browser's composers (Story Mode's prompt bar, the phone's Compose sheet) never send
|
||||
* a `text\r` pair: they write one bracketed paste and a bare Enter about 120 ms later,
|
||||
* because Codex drops a key that shares a read with a paste. `PastedPromptWatch` pairs
|
||||
* those two writes back up so the same check covers them. A long paste is not on the
|
||||
* composer as text at all (Claude Code draws `[Pasted text #1 +3 lines]`), so that
|
||||
* stand-in counts as the prompt only when its line count is the prompt's own.
|
||||
*
|
||||
* Pure apart from the injected capture, send and log, so the schedule, the cap and
|
||||
* every stop condition are unit-tested with fake timers (test/session-submit-verifier.test.ts).
|
||||
*/
|
||||
@@ -36,33 +43,105 @@ export const SUBMIT_VERIFY_DELAYS_MS: readonly number[] = [
|
||||
2_000, 3_000, 5_000, 5_000, 5_000, 10_000, 10_000, 10_000, 10_000,
|
||||
];
|
||||
|
||||
/** How many leading characters of the prompt have to match, whitespace removed. */
|
||||
/**
|
||||
* How many leading characters of the prompt have to match, whitespace removed. A prompt
|
||||
* shorter than this has to match whole, so a one-word answer cannot read a selection
|
||||
* dialog's highlighted row (`❯ 1. Yes`) as itself.
|
||||
*/
|
||||
const PROMPT_HEAD_CHARS = 24;
|
||||
|
||||
const compact = (s: string): string => s.replace(/\s+/g, '');
|
||||
|
||||
/**
|
||||
* Claude Code's stand-in for a long paste, whitespace removed (measured on 2.1.303):
|
||||
* `[Pasted text #1]` for one line over 800 characters, `[Pasted text #1 +3 lines]` for
|
||||
* four lines or more, the number counting line BREAKS. `#n` counts pastes, so it is never
|
||||
* pinned. Codex's `[Pasted Content N chars]` is not matched: not measured here.
|
||||
*/
|
||||
const PASTE_PLACEHOLDER = /^\[Pastedtext#\d+(?:\+(\d+)lines?)?\]$/;
|
||||
|
||||
/** The box-drawing rule Claude Code draws under its composer, which ends the block. */
|
||||
const RULE_LINE = /^[\u2500-\u257f]/;
|
||||
|
||||
const lineBreaks = (s: string): number => (s.match(/\r\n|\r|\n/g) ?? []).length;
|
||||
|
||||
/**
|
||||
* Whether `prompt` is still sitting unsubmitted in the composer of `screen`.
|
||||
*
|
||||
* - `true`: the last `glyph` line holds the prompt's head.
|
||||
* - `true`: the last `glyph` line holds the prompt's head, or the paste stand-in for a
|
||||
* prompt with that many lines.
|
||||
* - `false`: the composer is empty (the prompt was taken) or holds other text.
|
||||
* - `undefined`: no composer line at all; nothing can be said, so nothing is sent.
|
||||
*
|
||||
* Whitespace is removed on both sides before comparing, because the composer wraps a
|
||||
* long prompt onto indented continuation lines and Claude Code draws a no-break space
|
||||
* after the glyph; `\s` covers that one in JavaScript.
|
||||
* after the glyph; `\s` covers that one in JavaScript. Those continuation lines (up to a
|
||||
* blank line or the rule) count too, for a prompt whose first line is short.
|
||||
*/
|
||||
export function promptStillInComposer(screen: string, prompt: string, glyph: string): boolean | undefined {
|
||||
if (!glyph) return undefined;
|
||||
const composerLines = stripAnsi(screen)
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith(glyph));
|
||||
if (composerLines.length === 0) return undefined;
|
||||
const composer = compact(composerLines[composerLines.length - 1].slice(glyph.length));
|
||||
if (!composer) return false;
|
||||
const head = compact(prompt).slice(0, PROMPT_HEAD_CHARS);
|
||||
return head.length > 0 && composer.startsWith(head);
|
||||
const lines = stripAnsi(screen).split('\n');
|
||||
let at = lines.length - 1;
|
||||
while (at >= 0 && !lines[at].trim().startsWith(glyph)) at--;
|
||||
if (at < 0) return undefined;
|
||||
const first = compact(lines[at].trim().slice(glyph.length));
|
||||
if (!first) return false;
|
||||
const placeholder = PASTE_PLACEHOLDER.exec(first);
|
||||
if (placeholder) return Number(placeholder[1] ?? 0) === lineBreaks(prompt);
|
||||
const wanted = compact(prompt);
|
||||
if (!wanted) return false;
|
||||
const head = wanted.slice(0, PROMPT_HEAD_CHARS);
|
||||
const holds = (composer: string): boolean =>
|
||||
wanted.length >= PROMPT_HEAD_CHARS ? composer.startsWith(head) : composer === wanted;
|
||||
if (holds(first)) return true;
|
||||
let block = first;
|
||||
for (let i = at + 1; i < lines.length; i++) {
|
||||
const text = lines[i].trim();
|
||||
if (!text || !/^\s/.test(lines[i]) || RULE_LINE.test(text)) break;
|
||||
block += compact(text);
|
||||
}
|
||||
return block !== first && holds(block);
|
||||
}
|
||||
|
||||
/** How long after a paste a bare Enter still counts as submitting it. */
|
||||
export const PASTE_ENTER_WINDOW_MS = 10_000;
|
||||
|
||||
const PASTE_START = '\x1b[200~';
|
||||
const PASTE_END = '\x1b[201~';
|
||||
|
||||
/** The text of a write that is exactly one bracketed paste, else null. */
|
||||
export function bracketedPasteText(data: string): string | null {
|
||||
if (data.length < PASTE_START.length + PASTE_END.length) return null;
|
||||
if (!data.startsWith(PASTE_START) || !data.endsWith(PASTE_END)) return null;
|
||||
const text = data.slice(PASTE_START.length, -PASTE_END.length);
|
||||
return text.includes(PASTE_START) || text.includes(PASTE_END) ? null : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs a composer's two writes: one bracketed paste, then a bare Enter. Fed every
|
||||
* write to the pane; anything between the two breaks the pair, so the Enter is
|
||||
* only ever matched with the paste right before it.
|
||||
*/
|
||||
export class PastedPromptWatch {
|
||||
private pending: { text: string; at: number } | null = null;
|
||||
|
||||
constructor(private readonly windowMs: number = PASTE_ENTER_WINDOW_MS) {}
|
||||
|
||||
/**
|
||||
* `paste` when `data` is a paste (a newer one owns the composer now), `submit` with
|
||||
* the pasted text when it is the Enter that sends the paste just before it.
|
||||
*/
|
||||
feed(data: string, now: number): { kind: 'paste' } | { kind: 'submit'; text: string } | null {
|
||||
const pasted = bracketedPasteText(data);
|
||||
if (pasted !== null) {
|
||||
this.pending = pasted.trim() ? { text: pasted, at: now } : null;
|
||||
return { kind: 'paste' };
|
||||
}
|
||||
const pending = this.pending;
|
||||
this.pending = null;
|
||||
if (data !== '\r' || !pending || now - pending.at > this.windowMs) return null;
|
||||
return { kind: 'submit', text: pending.text };
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmitVerifierDeps {
|
||||
|
||||
+26
-4
@@ -125,7 +125,7 @@ import {
|
||||
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { SubmitVerifier } from './session-submit-verifier.js';
|
||||
import { PastedPromptWatch, SubmitVerifier } from './session-submit-verifier.js';
|
||||
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
||||
import {
|
||||
@@ -646,6 +646,7 @@ export class Session extends EventEmitter {
|
||||
private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below)
|
||||
/** Re-sends Enter while a programmatic prompt still sits in the composer (session-submit-verifier.ts). */
|
||||
private _submitVerifier: SubmitVerifier | null = null;
|
||||
private readonly _pastedPromptWatch = new PastedPromptWatch();
|
||||
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
|
||||
private _taskTracker: TaskTracker;
|
||||
|
||||
@@ -4598,6 +4599,7 @@ export class Session extends EventEmitter {
|
||||
if (!this.ptyProcess) return false;
|
||||
this.ptyProcess.write(data);
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
this._notePastedPrompt(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4746,6 +4748,7 @@ export class Session extends EventEmitter {
|
||||
if (sent) {
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
this._verifySubmitted(data);
|
||||
this._notePastedPrompt(data);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -4775,9 +4778,28 @@ export class Session extends EventEmitter {
|
||||
* can read its pane, only text can be stranded, and the glyph is the CLI's own.
|
||||
*/
|
||||
private _verifySubmitted(data: string): void {
|
||||
if (!data.includes('\r') || !this._mux?.capturePaneText || !this._muxSession) return;
|
||||
const text = data.replace(/[\r\n]/g, '').trimEnd();
|
||||
if (!text) return;
|
||||
if (!data.includes('\r')) return;
|
||||
this._armSubmitVerifier(data.replace(/[\r\n]/g, '').trimEnd());
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser composers (Story Mode's prompt bar, the phone's Compose sheet) send a
|
||||
* prompt as a bracketed paste and then a bare Enter, as two writes: over the
|
||||
* WebSocket both land in `write()`, on the POST fallback the Enter takes
|
||||
* `writeViaMux()`. Neither is a `text\r` pair, so the Enter armed nothing and a
|
||||
* prompt sent while Claude Code was ignoring Enter sat unsent. This pairs them
|
||||
* (PastedPromptWatch) and checks the pasted text, line breaks kept so a paste the
|
||||
* CLI shows as `[Pasted text #1 +3 lines]` is still recognised. A newer paste
|
||||
* cancels any pending check: pressing Enter then would send the user's paste too.
|
||||
*/
|
||||
private _notePastedPrompt(data: string): void {
|
||||
const seen = this._pastedPromptWatch.feed(data, Date.now());
|
||||
if (seen?.kind === 'paste') this._submitVerifier?.cancel();
|
||||
else if (seen?.kind === 'submit') this._armSubmitVerifier(seen.text);
|
||||
}
|
||||
|
||||
private _armSubmitVerifier(text: string): void {
|
||||
if (!text.trim() || !this._mux?.capturePaneText || !this._muxSession) return;
|
||||
this._submitVerifier ??= new SubmitVerifier({
|
||||
capture: () =>
|
||||
this._isStopped || !this._mux || !this._muxSession
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): one layer per press: a file preview opened from a panel (it has no Escape of its own outside the folder grid), then the drawer, then the overlay; a folder grid on top and a preview in edit mode keep their own keys. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. ⚠️ Wide panels draw their scene in a centered stage (`STORY_STAGE_MAX`); a scene with `fullWidth` (the final splash) uses the whole panel. ⚠️ A panel GROWS to its words instead of covering or clipping them: the final panel's words and chips are IN FLOW (`.story-splash`), and any other panel whose caption reaches past the band above its art gets an inline `min-height` (`_storyFitCaption`, re-run on resize and on the Director's cut toggle); rows grow with them. Every scene keeps the panel's usual height (`--story-panel-min`) as its stage (`rt.stageH`) and stands at the bottom (`oy`), background filling the rest, or a taller panel would scale Claude up with it. ⚠️ The page fills the window: `storyRowHeights()` (pure) shares the main area's visible height between the rows (never under 270px, so a long page scrolls; capped per row so a narrow panel never turns portrait), and JS writes only plain px (`--story-panel-min` per panel, `--story-row-h`, and the text scale `--story-k` on `.story-page`, both registered with `@property`): `_storySizePanel` and `_storyFitCaption` read them with `parseFloat`, so a `clamp()`/`calc()` there reads as NaN and silently turns caption fitting off. Comic font sizes are `calc(<n>px * var(--story-k, 1))`. ⚠️ **Save image** composites the page itself (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box, `_storyPaintBox`); never rasterize the DOM through SVG (self-hosted fonts do not reach it). Session Options carries a Story button too (`#sessionOptionsStoryBtn`, same setting), the way in on phones. ⚠️ Every speech balloon is placed by `_storyFitBalloon()` (called from `_storyFitCaption`) from the scene's layout at its FINISHED frame (`StoryArt.layout`, a dry run on a scratch canvas: never the frame on screen, where an entrance still has objects in flight): Claude's in the band above his head (`agent()` without a `body`, so a cyan helper never holds it), the ask panel's and yours left of his face. A balloon never covers an object a scene registers with `hit()` nor Claude's face; a click region that holds his face is Claude himself and only the face rule applies to it. When there is no room for two lines (a long caption pushes it down), the panel GROWS for caption and balloon together; the growth is computed at the panel's usual height (`storyBalloonRoom`, pure), so a refit of the grown panel asks for the same height and never oscillates; the rest clamps (`--story-lines`, `_storyClampBalloon`). Never position a balloon per kind in CSS (that is what covered the POW burst and the agent's head on narrow panels). Drawer prose (a prompt, a helper's answer) links rooted paths only. ⚠️ Every file a panel or drawer opens goes through `_storyOpenFile()`: keyboard focus moves to the preview's close button, Tab cycles inside the preview while it is open (`_storyTrapTab`), and Escape gives focus back to what opened it. ⚠️ Panels are kept by IDENTITY (kind + first event id, `rt.identity`), never by index: a fold into a montage near the start shifts every later index and must not rebuild (and re-animate) them. ⚠️ A balloon's text is `.story-balloon-text`, after the visually hidden speaker words (`.story-vh`, "Claude says: "): the clamp and Save image must find the text span by class, never `firstElementChild` (that is how long balloons once stopped clamping). ⚠️ The drawer keeps its nodes when a refresh changes nothing (`st.drawerSig`), and a real change rebuilds and gives focus back to the control that does the same thing; helper steps arLine truncated
|
||||
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): one layer per press: a file preview opened from a panel (it has no Escape of its own outside the folder grid), then the drawer, then the overlay; a folder grid on top and a preview in edit mode keep their own keys. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. ⚠️ Wide panels draw their scene in a centered stage (`STORY_STAGE_MAX`); a scene with `fullWidth` (the final splash) uses the whole panel. ⚠️ A panel GROWS to its words instead of covering or clipping them: the final panel's words and chips are IN FLOW (`.story-splash`), and any other panel whose caption reaches past the band above its art gets an inline `min-height` (`_storyFitCaption`, re-run on resize and on the Director's cut toggle); rows grow with them. Every scene keeps the panel's usual height (`--story-panel-min`) as its stage (`rt.stageH`) and stands at the bottom (`oy`), background filling the rest, or a taller panel would scale Claude up with it. ⚠️ The page fills the window: `storyRowHeights()` (pure) shares the main area's visible height between the rows (never under 270px, so a long page scrolls; capped per row so a narrow panel never turns portrait), and JS writes only plain px (`--story-panel-min` per panel, `--story-row-h`, and the text scale `--story-k` on `.story-page`, both registered with `@property`): `_storySizePanel` and `_storyFitCaption` read them with `parseFloat`, so a `clamp()`/`calc()` there reads as NaN and silently turns caption fitting off. Comic font sizes are `calc(<n>px * var(--story-k, 1))`. ⚠️ **Save image** composites the page itself (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box, `_storyPaintBox`); never rasterize the DOM through SVG (self-hosted fonts do not reach it). Session Options carries a Story button too (`#sessionOptionsStoryBtn`, same setting), the way in on phones. ⚠️ Every speech balloon is placed by `_storyFitBalloon()` (called from `_storyFitCaption`) from the scene's layout at its FINISHED frame (`StoryArt.layout`, a dry run on a scratch canvas: never the frame on screen, where an entrance still has objects in flight): Claude's in the band above his head (`agent()` without a `body`, so a cyan helper never holds it), the ask panel's and yours left of his face. A balloon never covers an object a scene registers with `hit()` nor Claude's face; a click region that holds his face is Claude himself and only the face rule applies to it. When there is no room for two lines (a long caption pushes it down), the panel GROWS for caption and balloon together; the growth is computed at the panel's usual height (`storyBalloonRoom`, pure), so a refit of the grown panel asks for the same height and never oscillates; the rest clamps (`--story-lines`, `_storyClampBalloon`). Never position a balloon per kind in CSS (that is what covered the POW burst and the agent's head on narrow panels). Drawer prose (a prompt, a helper's answer) links rooted paths only. ⚠️ Every file a panel or drawer opens goes through `_storyOpenFile()`: keyboard focus moves to the preview's close button, Tab cycles inside the preview while it is open (`_storyTrapTab`), and Escape gives focus back to what opened it. ⚠️ Panels are kept by IDENTITY (kind + first event id, `rt.identity`), never by index: a fold into a montage near the start shifts every later index and must not rebuild (and re-animate) them. ⚠️ A balloon's text is `.story-balloon-text`, after the visually hidden speaker words (`.story-vh`, "Claude says: "): the clamp and Save image must find the text span by class, never `firstElementChild` (that is how long balloons once stopped clamping). ⚠️ The drawer keeps its nodes when a refresh changes nothing (`st.drawerSig`), and a real change rebuilds and gives focus back to the control that does the same thing; helper steps arLine truncated
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
* the verifier reads the last glyph line, re-sends Enter only while the prompt is
|
||||
* verifiably still there, stops the moment it is gone, never acts on a pane with no
|
||||
* composer, is capped, and is cancelled by a newer write or teardown.
|
||||
*
|
||||
* The browser's composers send a paste and a bare Enter as two writes, which used to arm
|
||||
* nothing; the second half pins the pairing, through a real Session on both transports.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SubmitVerifier, promptStillInComposer, SUBMIT_VERIFY_DELAYS_MS } from '../src/session-submit-verifier.js';
|
||||
import {
|
||||
SubmitVerifier,
|
||||
promptStillInComposer,
|
||||
SUBMIT_VERIFY_DELAYS_MS,
|
||||
bracketedPasteText,
|
||||
PastedPromptWatch,
|
||||
PASTE_ENTER_WINDOW_MS,
|
||||
} from '../src/session-submit-verifier.js';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
const PROMPT =
|
||||
'Read /home/arkon/.codeman/pr-bot/jobs/pr-439/brief.md and carry out the review it describes. Do not ask questions.';
|
||||
@@ -175,3 +186,172 @@ describe('SubmitVerifier', () => {
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/** Composer chrome measured on Claude Code 2.1.303 (2026-10-19); the prompts are invented. */
|
||||
const RULE = '────────────────────────────────────────────────────────────────';
|
||||
const pane = (...composer: string[]) =>
|
||||
[
|
||||
' ▐▛███▛█ Claude Code v2.1.303',
|
||||
RULE,
|
||||
...composer,
|
||||
RULE,
|
||||
' Opus 5.5 in:0 out:0',
|
||||
' paste again to expand',
|
||||
].join('\n');
|
||||
|
||||
describe('promptStillInComposer: what a pasted prompt looks like', () => {
|
||||
it('reads a short multi-line paste, shown inline, as the prompt', () => {
|
||||
const prompt = 'Fix it\rThen run the tests\rand commit';
|
||||
expect(promptStillInComposer(pane('❯\xa0Fix it', ' Then run the tests', ' and commit'), prompt, '❯')).toBe(true);
|
||||
expect(promptStillInComposer(pane('❯\xa0'), prompt, '❯')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a long word wrapped off the first line', () => {
|
||||
const prompt = `Line 1 ${'x'.repeat(200)}`;
|
||||
expect(promptStillInComposer(pane('❯\xa0Line 1', ` ${'x'.repeat(118)}`, ` ${'x'.repeat(82)}`), prompt, '❯')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('counts the stand-in for a long paste only when its line count is the prompt’s', () => {
|
||||
const fourLines = 'a\rb\rc\rd';
|
||||
expect(promptStillInComposer(pane('❯\xa0[Pasted text #1 +3 lines]'), fourLines, '❯')).toBe(true);
|
||||
// `#n` counts pastes, so a later one is still ours.
|
||||
expect(promptStillInComposer(pane('❯\xa0[Pasted text #4 +3 lines]'), fourLines, '❯')).toBe(true);
|
||||
expect(promptStillInComposer(pane('❯\xa0[Pasted text #1 +1 lines]'), fourLines, '❯')).toBe(false);
|
||||
expect(promptStillInComposer(pane('❯\xa0[Pasted text #1]'), fourLines, '❯')).toBe(false);
|
||||
const oneLong = 'y'.repeat(900);
|
||||
expect(promptStillInComposer(pane('❯\xa0[Pasted text #1]'), oneLong, '❯')).toBe(true);
|
||||
expect(promptStillInComposer(pane('❯\xa0[Pasted text #1 +3 lines]'), oneLong, '❯')).toBe(false);
|
||||
});
|
||||
|
||||
it('never takes the stand-in plus typed text, or a dialog row, for the prompt', () => {
|
||||
expect(promptStillInComposer(pane('❯\xa0[Pasted text #1] and more'), 'y'.repeat(900), '❯')).toBe(false);
|
||||
// A short answer has to match whole: `1` is not the highlighted `1. Yes`.
|
||||
expect(promptStillInComposer(pane('❯ 1. Yes', ' 2. No'), '1', '❯')).toBe(false);
|
||||
expect(promptStillInComposer(pane('❯ yes please'), 'yes', '❯')).toBe(false);
|
||||
expect(promptStillInComposer(pane('❯ yes'), 'yes', '❯')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bracketedPasteText', () => {
|
||||
it('returns the text of exactly one paste and nothing else', () => {
|
||||
expect(bracketedPasteText('\x1b[200~hello\rworld\x1b[201~')).toBe('hello\rworld');
|
||||
expect(bracketedPasteText('hello\r')).toBeNull();
|
||||
expect(bracketedPasteText('\x1b[200~hello\x1b[201~\r')).toBeNull();
|
||||
expect(bracketedPasteText('\x1b[200~a\x1b[201~\x1b[200~b\x1b[201~')).toBeNull();
|
||||
expect(bracketedPasteText('\x1b[200~')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PastedPromptWatch', () => {
|
||||
const paste = (t: string) => `\x1b[200~${t}\x1b[201~`;
|
||||
|
||||
it('pairs a paste with the bare Enter right after it', () => {
|
||||
const w = new PastedPromptWatch();
|
||||
expect(w.feed(paste('Reply with ready'), 0)).toEqual({ kind: 'paste' });
|
||||
expect(w.feed('\r', 120)).toEqual({ kind: 'submit', text: 'Reply with ready' });
|
||||
// Spent: a second Enter submits nothing of ours.
|
||||
expect(w.feed('\r', 200)).toBeNull();
|
||||
});
|
||||
|
||||
it('lets nothing come between the two, and nothing arrive late', () => {
|
||||
const w = new PastedPromptWatch();
|
||||
w.feed(paste('one'), 0);
|
||||
expect(w.feed('x', 50)).toBeNull();
|
||||
expect(w.feed('\r', 100)).toBeNull();
|
||||
w.feed(paste('two'), 1_000);
|
||||
expect(w.feed('\r', 1_000 + PASTE_ENTER_WINDOW_MS + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an Enter with no paste before it, and an empty paste', () => {
|
||||
const w = new PastedPromptWatch();
|
||||
expect(w.feed('\r', 0)).toBeNull();
|
||||
expect(w.feed(paste(' '), 10)).toEqual({ kind: 'paste' });
|
||||
expect(w.feed('\r', 20)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session: a composer send is checked like any other prompt', () => {
|
||||
const STRANDED_PASTE = pane('❯\xa0Reply with only the word ready.');
|
||||
const EMPTY = pane('❯\xa0');
|
||||
let screen: string;
|
||||
let muxEnters: number;
|
||||
|
||||
/** A session with a scripted pane, a recording mux and a PTY that swallows writes. */
|
||||
function makeSession(): Session {
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneText: () => screen,
|
||||
sendInput: async (_id: string, input: string) => {
|
||||
if (input === '\r') muxEnters++;
|
||||
return true;
|
||||
},
|
||||
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||
} as ConstructorParameters<typeof Session>[0]);
|
||||
(session as unknown as { ptyProcess: { write: (d: string) => void } }).ptyProcess = { write: () => {} };
|
||||
return session;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
screen = STRANDED_PASTE;
|
||||
muxEnters = 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('WebSocket: paste then Enter, both raw writes, re-presses Enter until the prompt leaves', async () => {
|
||||
const session = makeSession();
|
||||
session.write('\x1b[200~Reply with only the word ready.\x1b[201~', { fromUser: true });
|
||||
session.write('\r', { fromUser: true });
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(muxEnters).toBe(1);
|
||||
screen = EMPTY;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(muxEnters).toBe(1);
|
||||
});
|
||||
|
||||
it('POST fallback: the Enter goes through writeViaMux and arms the same check', async () => {
|
||||
const session = makeSession();
|
||||
session.write('\x1b[200~Reply with only the word ready.\x1b[201~', { fromUser: true });
|
||||
await session.writeViaMux('\r', { fromUser: true });
|
||||
expect(muxEnters).toBe(1); // the Enter itself
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(muxEnters).toBe(2);
|
||||
});
|
||||
|
||||
it('a taken prompt costs nothing more', async () => {
|
||||
screen = EMPTY;
|
||||
const session = makeSession();
|
||||
session.write('\x1b[200~Reply with only the word ready.\x1b[201~', { fromUser: true });
|
||||
session.write('\r', { fromUser: true });
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(muxEnters).toBe(0);
|
||||
});
|
||||
|
||||
it('a newer paste cancels the check, so Enter never sends the user’s own paste', async () => {
|
||||
const session = makeSession();
|
||||
session.write(`\x1b[200~${'y'.repeat(900)}\x1b[201~`, { fromUser: true });
|
||||
session.write('\r', { fromUser: true });
|
||||
// Taken; then the user pastes something long of their own into the terminal.
|
||||
session.write(`\x1b[200~${'z'.repeat(900)}\x1b[201~`, { fromUser: true });
|
||||
screen = pane('❯\xa0[Pasted text #2]');
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(muxEnters).toBe(0);
|
||||
});
|
||||
|
||||
it('typing in the terminal arms nothing', async () => {
|
||||
screen = pane('❯\xa0hi');
|
||||
const session = makeSession();
|
||||
for (const key of ['h', 'i', '\r']) session.write(key, { fromUser: true });
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(muxEnters).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user