Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (25 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 31 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 32 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
| **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |

★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
Expand Down Expand Up @@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph

### Frontend

Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke.

**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.

Expand Down
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [
'test/opencode-resize.test.ts',
'test/webgl-fallback.test.ts',
'test/terminal-copy-shortcut.test.ts',
'test/terminal-keycode229-recovery.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
];

Expand Down
2 changes: 2 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ appendFileSync(

// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
Expand Down Expand Up @@ -110,6 +111,7 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
'terminal-keycode229-recovery.js',
'sanitize-html.js',
'app.js',
'tab-rail-resize.js',
Expand Down
2 changes: 2 additions & 0 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3458,6 +3458,8 @@ <h3><span aria-hidden="true">🧠</span> Read My Mind</h3>
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
<script defer src="terminal-keycode229-recovery.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<script defer src="app.js"></script>
Expand Down
187 changes: 187 additions & 0 deletions src/web/public/terminal-keycode229-recovery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* @fileoverview Orphaned-input forwarder for xterm's helper textarea.
*
* xterm's `CoreBrowserTerminal._inputEvent` only forwards an `insertText`
* input event while `(!ev.composed || !this._keyDownSeen)` holds. A soft
* keyboard that delivers a `composed: true` input event after a keydown fails
* that guard, so xterm returns without emitting and the committed character is
* silently dropped.
*
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
* controller that looks useful while doing nothing. For a keydown that really
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
* .keydown()` calls `_handleAnyTextareaChanges()`, which snapshots
* `textarea.value` and diffs it on a 0 ms timer, emitting the difference
* itself. Measured in headless chromium against a real terminal: for a 229
* keydown xterm emits and this controller correctly stands down. What is left
* unrescued is a refused `insertText` where NO 229 diff was scheduled — that is
* the case this module exists for, and the case its browser test asserts by
* checking WHO delivered the byte rather than merely that one arrived.
*
* The recovery never guesses the character: the `input` event already carries
* the real committed text in `ev.data`, which is exactly what xterm itself
* would have forwarded. We only decide WHETHER to forward it, by asking
* whether xterm produced any canonical data since the keydown that started the
* keystroke. That snapshot must be taken at KEYDOWN, not at the input event:
* xterm's `_keyPress` emits and sets `_keyPressHandled` before `input` fires,
* so a snapshot read at input time would already contain that emission and the
* character would be delivered twice.
*
* Listener registration is load-bearing, in BOTH phase and order. xterm
* registers its own `input` listener in `terminal.open()` with `capture:
* true`, and ours is added afterwards, so at-target it runs second. It must
* also be a CAPTURE listener; see the measured table at the addEventListener
* call below.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
*/
(function (global) {
'use strict';

function create(options) {
const textarea = options?.textarea;
const emitRecovered = options?.emitRecovered;
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
return null;
}

const isScreenReaderMode = options.isScreenReaderMode;
const setTimer = options.setTimer || global.setTimeout.bind(global);
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);

let destroyed = false;
// Number of canonical data events xterm has emitted, bumped by the caller's
// onData hook. Only its ORDER relative to a keydown matters.
let canonicalCount = 0;
let keydownSnapshot = null;
let composing = false;
const pending = [];

function cancelPending() {
for (const candidate of pending.splice(0)) {
candidate.active = false;
if (candidate.timer !== null) {
try {
clearTimer(candidate.timer);
} catch {
// A broken timer host must not break input handling.
}
candidate.timer = null;
}
}
}

function resolveCandidate(candidate) {
const index = pending.indexOf(candidate);
if (index !== -1) pending.splice(index, 1);
candidate.timer = null;
if (!candidate.active || destroyed) return;
candidate.active = false;
// xterm (or its keypress path) spoke for this keystroke — it is already
// on its way to the PTY, so there is nothing to recover.
if (canonicalCount > candidate.snapshot) return;
try {
emitRecovered(candidate.data);
} catch {
// Recovery is best effort; a failed delivery must never throw into the
// browser's input handling.
}
}

/** Called from xterm's onData hook: xterm produced canonical data. */
function notifyCanonicalData() {
canonicalCount += 1;
}

/**
* Snapshot the canonical counter at every keydown. This deliberately reads
* NOTHING else off the event — not `key`, not `keyCode`. Gating it on
* keyCode 229 would make the recovery inert on exactly the devices it
* exists for, whose keydowns report `key: 'Unidentified'`. It is a single
* assignment, so running it for every keydown costs nothing.
*/
function handleKeyEvent(event) {
if (destroyed || event?.type !== 'keydown') return;
keydownSnapshot = canonicalCount;
}

function onInput(event) {
if (destroyed || composing || event?.isComposing) return;
if (event.inputType !== 'insertText') return;
const data = event.data;
if (typeof data !== 'string' || data === '') return;
try {
if (isScreenReaderMode?.()) return;
} catch {
return;
}

const candidate = {
data,
snapshot: keydownSnapshot ?? canonicalCount,
active: true,
timer: null,
};
pending.push(candidate);
try {
candidate.timer = setTimer(() => resolveCandidate(candidate), 0);
} catch {
cancelPending();
}
}

function onCompositionStart() {
if (destroyed) return;
composing = true;
cancelPending();
}

function onCompositionEnd() {
if (destroyed) return;
composing = false;
}

function destroy() {
if (destroyed) return;
destroyed = true;
cancelPending();
try {
textarea.removeEventListener('input', onInput, true);
textarea.removeEventListener('compositionstart', onCompositionStart, true);
textarea.removeEventListener('compositionend', onCompositionEnd, true);
} catch {
// Teardown is best effort; the terminal is being replaced anyway.
}
}

// capture: true, not bubble. The target (the textarea) is visited TWICE in
// the event path, so a capture-phase listener on it calling
// stopPropagation() still stops later BUBBLE-phase listeners on that same
// target. xterm's `_inputEvent` calls `this.cancel(ev)` (preventDefault +
// stopPropagation) exactly in the branch where it HANDLED the input, so on
// bubble we would never see handled events — and whether we saw them at
// all would hang off xterm's `options.cancelEvents`, which Codeman does not
// set. Measured (jsdom and headless chromium agree):
//
// capture-then-BUBBLE, no stop: xterm -> ours
// capture-then-BUBBLE, stopPropagation: xterm (ours never fires)
// capture-then-CAPTURE, no stop: xterm -> ours
// capture-then-CAPTURE, stopPropagation: xterm -> ours (still fires)
//
// On capture we therefore observe EVERY input event uniformly, and the
// canonicalCount snapshot alone decides whether to forward.
try {
textarea.addEventListener('input', onInput, true);
textarea.addEventListener('compositionstart', onCompositionStart, true);
textarea.addEventListener('compositionend', onCompositionEnd, true);
} catch {
destroy();
return null;
}

return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
}

global.CodemanKeyCode229Recovery = Object.freeze({ create });
})(typeof window !== 'undefined' ? window : globalThis);
52 changes: 51 additions & 1 deletion src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,12 +232,22 @@ Object.assign(CodemanApp.prototype, {
// Terminal Setup — xterm.js config and input handling
// ═══════════════════════════════════════════════════════════════

_destroyKeyCode229Recovery() {
try {
this._keyCode229Recovery?.destroy?.();
} catch {
// Recovery is optional; terminal replacement must continue.
}
this._keyCode229Recovery = null;
},

initTerminal() {
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
const stored = parseInt(localStorage.getItem('codeman-scrollback'));
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;

this._destroyKeyCode229Recovery();
this.terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
Expand Down Expand Up @@ -292,6 +302,16 @@ Object.assign(CodemanApp.prototype, {
// punctuation; returning false here would stop xterm before it can diff
// the helper textarea and emit the committed Unicode text.
this.terminal.attachCustomKeyEventHandler((ev) => {
try {
// Deliberately runs for EVERY keydown, not just keyCode 229: the
// controller snapshots a counter and reads nothing off the event, and
// the devices this exists for report `key: 'Unidentified'` with no
// reliable identity to gate on. Gating it would make recovery inert
// exactly where it is needed. Cost is one assignment.
this._keyCode229Recovery?.handleKeyEvent?.(ev);
} catch {
// The fallback must never interfere with xterm's canonical handler.
}
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;

// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
Expand Down Expand Up @@ -1026,7 +1046,7 @@ Object.assign(CodemanApp.prototype, {
// mobile connections. The overlay + localStorage persistence ensure input
// survives tab switches and reconnects.

this.terminal.onData((data) => {
const handleTerminalData = (data) => {
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
// the PTY even while the CJK input field owns focus. Without this exception
// tapping to move the cursor silently does nothing whenever Chinese input
Expand Down Expand Up @@ -1348,6 +1368,36 @@ Object.assign(CodemanApp.prototype, {
}
}
}
};

// Chrome on Android delivers a `composed: true` input event preceded by a
// keydown, which is exactly the shape xterm's _inputEvent refuses to
// forward, so the committed character is silently dropped. The controller
// forwards the input event's own `data` when xterm produced nothing for
// that keystroke. Created AFTER terminal.open() on purpose: for an event
// targeting the textarea, at-target listeners run in registration order,
// so xterm's listener (added in open()) still runs first. The controller
// registers its own listener with `capture: true`; on bubble xterm's
// `cancel()` (stopPropagation) would swallow exactly the handled events —
// see the measured table in terminal-keycode229-recovery.js.
try {
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
textarea: this.terminal.textarea,
emitRecovered: (data) => handleTerminalData(data),
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
});
} catch {
this._keyCode229Recovery = null;
}
this.terminal.onData((data) => {
// Canonical xterm data. Telling the controller is what lets it know a
// keystroke was already delivered and needs no recovery.
try {
this._keyCode229Recovery?.notifyCanonicalData?.();
} catch {
// Bookkeeping must never block real input.
}
handleTerminalData(data);
});
},

Expand Down
Loading