Compare commits

...
Author SHA1 Message Date
Codeman maintainer 9c22114b5a fix(mobile): remove /compact button from keyboard accessory bar (reintroduced in 1.1.10)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:13:23 +02:00
Codeman maintainer 98b2124d7e feat(ultracode): enrich live run tracking — real per-agent tokens/tools/state, readable title, blue connector line, click-to-open window
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:00:04 +02:00
Codeman maintainer bdaec320f5 chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:26:42 +02:00
Ark0N dfe20a3742 Merge PR #131: terminal touch tap interaction + forced redraw resize
feat(terminal): touch tap interaction + forced redraw resize
2026-06-17 18:08:07 +02:00
Ark0N de5216b83f Merge PR #130: mobile CJK input reliability + iPad keyboard accessory bar
fix(mobile): CJK input reliability + iPad keyboard accessory bar
2026-06-17 18:02:37 +02:00
Codeman maintainer 57eefd7aa5 fix(terminal): don't scroll/fling on a sub-threshold tap
The touchmove handler accumulated pixelAccum/velocity and could scrollLines
on every move — including micro-drift below the 8px tap threshold. A jittery
tap (<8px) stayed classified as a tap (didScroll=false, so tap-to-position
fired) yet still left a non-zero velocity, which touchend turned into a
momentum fling. Result: one tap both positioned the cursor and scrolled.

Gate the scroll/velocity accumulation behind didScroll so sub-threshold
movement is inert, matching the handler's stated tap-vs-scroll intent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:02:21 +02:00
Teigen 2c81bbc08b feat(terminal): add forced redraw resize 2026-06-17 23:41:47 +08:00
Teigen b374121c18 fix(mobile): prevent terminal tap selection 2026-06-17 23:40:21 +08:00
Teigen b1c4330680 fix(mobile): add tap threshold to terminal touch handler
touchmove fires on any 1px finger drift, marking didScroll=true and
skipping the tap handler (which refocuses terminal/CJK input). On
iPad's large touch surface and phones with imprecise taps, this makes
terminal tap unreliable — cjkActive gets stuck true, blocking all
input (CJK and paste).

Add 8px TAP_THRESHOLD: finger movement under 8px is still a tap.
Also add touch-action:none on .touch-device .terminal-container
so the browser doesn't consume touch events before our JS handler.
2026-06-17 23:40:21 +08:00
Teigen 47359e4002 fix(iPad): enable terminal touch interaction on all touch devices
touch-action: none was only set inside @media (max-width: 430px),
so iPad's browser consumed touch events before the JS scroll/tap
handler could preventDefault. Move to .touch-device class in
styles.css so it applies at any screen width.
2026-06-17 23:40:21 +08:00
Teigen a8e7d60db4 fix(iPad): show stop button on touch devices 2026-06-17 23:40:21 +08:00
Teigen 8dc70a5f1d fix(mobile): restore /compact button to keyboard accessory bar
Reverts eb83148 which removed the /compact button from both simple
and extended accessory bar modes. Restores double-tap confirmation
and refocus guard for the compact action.
2026-06-17 23:40:04 +08:00
Teigen 4d129086d1 fix(iPad): raise toolbar z-index when case settings popover is open
backdrop-filter on the toolbar creates a stacking context that traps
the popover's z-index (1000) inside the toolbar. CJK input (z-index 52)
in the root stacking context always wins. Use :has() to raise the
toolbar above CJK only while the popover is visible.
2026-06-17 23:40:04 +08:00
Teigen 566c65c3c9 fix(iPad): accessory bar styling, positioning, and paste dialog
Move keyboard accessory bar and paste dialog CSS from mobile.css
(gated behind max-width: 1023px) to styles.css (always loaded).
iPad landscape (≥1024px) was getting unstyled white buttons.

- Add position:fixed via .touch-device class for accessory bar
- Fix dismiss button: gray-blue → blue, matching phone styling
- JS: position accessory bar above keyboard on iPad via direct bottom
- JS: position CJK above accessory bar (bottom: keyboardHeight + 44)
- Clear accessory bar bottom in resetLayout()
2026-06-17 23:40:04 +08:00
Teigen cd7d8c7329 fix(mobile): split CJK keyboard positioning by device size
Phones use translateY(-keyboardOffset) — CSS bottom is relative to layout
viewport and keyboardOffset reliably lifts it above the keyboard (iOS
doesn't auto-scroll the visual viewport for the CJK textarea on phones).

iPad uses direct bottom positioning from keyboard height — translateY
broke because iOS auto-scrolls the visual viewport when the CJK textarea
receives focus, making keyboardOffset approach 0.
2026-06-17 23:40:04 +08:00
Teigen c55af9ec39 fix(iPad): CJK input positioning, paste dialog, and voice dictation duplication
Three iPad-specific issues fixed:

1. CJK input hidden behind keyboard: updateLayoutForKeyboard() gate changed
   from screen-size to touch-device detection. On iPad, CJK textarea (always
   position:fixed) gets bottom offset computed from keyboard HEIGHT directly
   instead of keyboardOffset (which depends on visualViewport.offsetTop that
   iOS adjusts when the CJK textarea receives focus). Toolbar/accessory bar
   transforms remain phone-only (they're normal-flow on iPad).

2. Paste dialog invisible on iPad: paste overlay CSS was inside
   @media (max-width: 430px) phone breakpoint — iPad (≥768px) had no styling.
   Extracted to universal section alongside keyboard accessory bar styles.

3. Voice dictation character duplication (Doubao/third-party IME):
   iOS voice dictation does NOT fire composition events (WebKit Bug 261764).
   Text arrives as bare input events; refinement is a delete→reinsert cycle.
   Rewrote CJK input handler with two-tier debounce:
   - Keyboard typing (no delete/replacement events): 150ms debounce
   - Dictation mode (deleteContentBackward or insertReplacementText detected):
     1500ms debounce, persists 3s to cover multi-word dictation
   - Composition path (compositionend): immediate flush, unchanged
   - Keydown singles/Enter/Esc/Ctrl: immediate, unchanged
   Also: keep cjkActive=true on blur while CJK is visible (prevents xterm
   from processing duplicate input when iOS dictation UI steals focus);
   keydown single-char sends tracked via timestamp to suppress the echo
   input event that third-party IMEs fire despite preventDefault.
2026-06-17 23:40:04 +08:00
Teigen 1a54217bfb fix(mobile): don't clear textarea during compositionstart
Programmatic _textarea.value = '' during compositionstart cancels the
active IME composition on iOS Safari, breaking Chinese character input.
The phantom (U+200B) is invisible and _strip() already removes it
before sending to PTY — no need to clear it manually.
2026-06-17 23:40:04 +08:00
Teigen 70742d400a fix(mobile): restore real-time CJK input and terminal tap interaction
Root cause: the mobile-composer mode (02fa3f3) routed CJK text through
local-echo buffering, which accumulated characters until Enter instead
of sending each composed word to the PTY immediately. Additionally,
xtermFocusRedirect hijacked all terminal taps, preventing cursor
positioning and scroll interaction.

Changes:
- Remove mobile-composer accumulation mode from input-cjk.js — all
  platforms now use the same immediate-flush path (compositionend →
  flush → PTY)
- Bypass local-echo buffering in _handleCjkInput (terminal-ui.js) —
  the CJK textarea already provides visual feedback
- Remove xtermFocusRedirect so terminal taps work normally again
- Reduce CJK textarea height (34px min, 6px padding) for less
  screen intrusion
- Paste dialog now sends Enter after text so pasted content submits
- Hide CJK textarea on welcome screen (no active session)
- Add Opus 4.6 model options to selector
2026-06-17 23:40:04 +08:00
Codeman maintainer d5809d1808 docs(CLAUDE.md): note 1.1.9 tunnel opt-in (acknowledgeUnauthTunnel) in COD-55 line
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:50:58 +02:00
25 changed files with 1325 additions and 513 deletions
+42
View File
@@ -1,5 +1,47 @@
# aicodeman
## 1.1.12
### Patch Changes
- Remove the `/compact` button from the mobile keyboard accessory bar. It had been reintroduced in 1.1.10; this removes the button from both the simple and full accessory-bar layouts (the underlying command handler is left in place as inert plumbing).
## 1.1.11
### Patch Changes
- Ultracode (Workflow-tool) run visualization — much better live tracking.
While a run is in flight, the watcher previously showed empty agent slots ("agent N", 0 tokens, raw `wf_…` id as the title) because the detailed completion JSON only lands when the run finishes. The live path now enriches in-flight runs directly from the on-disk transcript tree:
- **Real per-agent stats mid-run** — tokens and tool-call counts are parsed from each `agent-<id>.jsonl` transcript (tool counts match the final accounting exactly; token totals land within ~1% of the completion value), with model and a prompt preview. All mtime-cached (transcripts, journal, and script meta) so idle polls do no extra reads.
- **Readable window/run title** — workflow name, summary, and phases are derived from the persisted `workflows/scripts/<name>-<runId>.js` instead of showing the raw run id.
- **Agent status colors** — done agents show green, working agents show yellow (this also fixes the run/agent status badges, which referenced undefined `--success`/`--warning` CSS variables and were rendering with no color).
- **Connector line** — the floating-window → session-tab line now uses the session-tab accent blue (was purple).
- **Click a run to open its floating window** — clicking a workflow in the dock panel opens (or focuses) its floating window with the connector line, in addition to the auto-popped windows.
- Agents are ordered by journal launch order; concurrent run-detail fetches are de-duplicated.
## 1.1.10
### Patch Changes
- Mobile CJK input, iPad keyboard accessory bar, and terminal touch interaction fixes (PRs #130, #131).
Mobile / CJK (#130):
- Restore reliable real-time CJK (e.g. Pinyin) composition in the always-visible textarea, and refocus input when the terminal is tapped.
- Stop clearing the textarea during `compositionstart` — some IMEs include existing text in the composition region, and clearing it mid-composition corrupted input.
- iPad-specific fixes: `#cjkInput` positioning, paste-dialog placement, and duplicated voice-dictation output.
- Split CJK keyboard positioning by device size (phones vs iPad use different keyboard offsets).
- iPad accessory-bar styling/positioning: moved the accessory-bar and paste-overlay base styles out of the `max-width:1023px`-gated mobile stylesheet so iPad landscape (≥1024px) renders them correctly.
- Raise the toolbar stacking context while the case-settings popover is open so the popover is no longer hidden behind the toolbar.
- Restore the `/compact` button to the keyboard accessory bar (with double-tap confirmation, like `/clear`); the paste dialog now submits pasted text on "Send".
Terminal touch + forced redraw (#131):
- Enable terminal touch interaction on all touch devices and show the stop button on touch devices.
- Add an 8px tap threshold so micro-drift is treated as a tap, not a scroll, fixing cases where a tap failed to register.
- Tap-to-position the cursor via a synthesized mouse report, gated on the live mouse-tracking mode so it never triggers local text selection when tracking is off; let SGR mouse reports through to the PTY even while the CJK input field owns focus.
- Suppress the cursor/momentum side effects of a sub-threshold tap so a jittery tap no longer both positions the cursor and starts a momentum fling.
- New opt-in, per-device "Redraw Terminal" header button (`showRedrawButton`, default off) that forces an xterm redraw via a resize jitter to clear occasional rendering glitches; the resize path now accepts a `force` flag (threaded through the session, HTTP, and WebSocket resize routes) that guarantees a SIGWINCH/redraw at the current device's size without bypassing multi-client resize arbitration.
## 1.1.9
### Patch Changes
+3 -3
View File
@@ -56,7 +56,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.1.9 (must match `package.json`)
**Version**: 1.1.12 (must match `package.json`)
## Project Overview
@@ -132,7 +132,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
| **Types** | `src/types/index.ts` (barrel) → 16 domain files (incl. `workflow-run.ts`); 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`.
@@ -211,7 +211,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.1.9",
"version": "1.1.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.1.9",
"version": "1.1.12",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.1.9",
"version": "1.1.12",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+3 -2
View File
@@ -2321,7 +2321,7 @@ export class Session extends EventEmitter {
* @param cols - Number of columns (width in characters)
* @param rows - Number of rows (height in lines)
*/
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void {
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
if (options.viewportType === 'desktop') {
this._lastDesktopDims = { cols, rows };
@@ -2334,7 +2334,8 @@ export class Session extends EventEmitter {
}
this._mobileSizeOverride = true;
}
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
if (this.ptyProcess && (dimsChanged || options.force)) {
this._ptyCols = cols;
this._ptyRows = rows;
if (this._mux && this._muxSession) {
-1
View File
@@ -2206,7 +2206,6 @@ class CodemanApp {
document.body.classList.toggle('cjk-input-visible', !!showCjk);
cjkEl.style.display = showCjk ? 'block' : 'none';
cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true');
if (showCjk && cjkEl.value === '\u200B') cjkEl.value = '';
if (!showCjk) window.cjkActive = false;
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard();
}
+8
View File
@@ -116,6 +116,7 @@
<span class="stat-value" id="statMem">--</span>
</div>
</div>
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
@@ -1058,6 +1059,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
<span class="settings-item-label">Redraw Terminal Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowRedrawButton">
<span class="slider"></span>
</label>
</div>
<!-- Tab Bar Section -->
<div class="settings-section-header">Tab Bar</div>
+123 -123
View File
@@ -14,13 +14,21 @@
* This means compositionstart fires even for English text, and compositionend
* may not fire until the user explicitly confirms (space, candidate tap).
*
* We use InputEvent.inputType to distinguish:
* - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
* - `insertText`: final committed text (confirmed word, punctuation, space)
* During composition, all input events are ignored — only compositionend
* triggers a flush (CJK candidate selection).
*
* During composition, `insertText` events are flushed immediately (punctuation,
* English words confirmed by IME). `insertCompositionText` waits for
* compositionend (CJK candidate selection).
* ## iOS dictation challenge (WebKit Bug 261764)
*
* iOS/iPadOS voice dictation does NOT fire composition events. Text arrives
* as bare input events with isComposing === false. Dictation refinement is
* a delete→reinsert cycle (deleteContentBackward + insertReplacementText),
* all within a few ms. Flushing on every input event would send irrevocable
* provisional text to the PTY, causing duplication when the IME replaces it.
*
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
* delete→reinsert cycle collapses into one flush of the final textarea value.
* Keyboard typing of single printable characters still goes through the
* keydown handler (immediate, no debounce).
*
* ## Phantom character for Android backspace
*
@@ -41,16 +49,30 @@
// eslint-disable-next-line no-unused-vars
const CjkInput = (() => {
let _textarea = null;
let _terminalContainer = null;
let _xtermTextarea = null;
let _send = null;
let _initialized = false;
let _composing = false;
let _flushTimer = null;
let _dictationActive = false;
let _dictationDecayTimer = null;
let _keydownSentAt = 0;
const _listeners = {};
// Zero-width space: always present in textarea so Android backspace has
// something to delete, triggering the `input` event we need to detect it.
const PHANTOM = '\u200B';
const PHANTOM = '​';
// Two-tier debounce for non-composition input:
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
// composition events even for keyboard CJK typing)
// - DICTATION: long debounce (iOS voice dictation sends delete→reinsert
// refinement cycles without composition events — WebKit Bug 261764)
//
// Dictation is detected by deleteContentBackward on non-empty text or
// insertReplacementText — signals that the IME is rewriting provisional
// text. Once detected, dictation mode persists for 3s (covers multi-word
// dictation with natural pauses between words).
const DEBOUNCE_KEYBOARD_MS = 150;
const DEBOUNCE_DICTATION_MS = 1500;
const DICTATION_DECAY_MS = 3000;
const PASSTHROUGH_KEYS = {
ArrowUp: '\x1b[A',
@@ -66,35 +88,15 @@ const CjkInput = (() => {
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
};
/** Strip phantom characters from a string */
function _strip(str) {
return str.replace(/\u200B/g, '');
return str.replace(/​/g, '');
}
/** Reset textarea to phantom-only state with cursor at end */
function _resetToPhantom() {
_textarea.value = PHANTOM;
_textarea.setSelectionRange(1, 1);
}
function _isMobileComposer() {
return !!(
_textarea &&
typeof MobileDetection !== 'undefined' &&
MobileDetection.isTouchDevice() &&
_textarea.classList.contains('cjk-input-visible')
);
}
function _resetInput() {
if (_isMobileComposer()) {
_textarea.value = '';
} else {
_resetToPhantom();
}
}
/** Check if textarea contains only phantom(s) or is empty — no real user text */
function _isEffectivelyEmpty() {
return !_strip(_textarea.value);
}
@@ -108,61 +110,77 @@ const CjkInput = (() => {
_resetToPhantom();
}
/** Cancel any pending debounced flush */
function _cancelDebouncedFlush() {
if (_flushTimer) {
clearTimeout(_flushTimer);
_flushTimer = null;
}
}
/** Mark that dictation rewriting is in progress */
function _enterDictationMode() {
_dictationActive = true;
clearTimeout(_dictationDecayTimer);
_dictationDecayTimer = setTimeout(() => {
_dictationActive = false;
_dictationDecayTimer = null;
}, DICTATION_DECAY_MS);
}
/** Schedule a flush after input settles */
function _debouncedFlush() {
_cancelDebouncedFlush();
const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS;
_flushTimer = setTimeout(() => {
_flushTimer = null;
_flush();
}, delay);
}
return {
init({ send }) {
if (_initialized) this.destroy();
_send = send;
_composing = false;
_flushTimer = null;
_textarea = document.getElementById('cjkInput');
if (!_textarea) return this;
_terminalContainer = document.getElementById('terminalContainer');
// Seed the phantom character for the hidden/immediate CJK path.
_resetInput();
_resetToPhantom();
_listeners.mousedown = (e) => { e.stopPropagation(); };
_listeners.focus = () => {
window.cjkActive = true;
if (_isMobileComposer() && _textarea.value === PHANTOM) {
_textarea.value = '';
return;
if (!_textarea.value) _resetToPhantom();
};
_listeners.blur = () => {
// Keep cjkActive while CJK input is visible — iOS dictation and system
// UI may steal focus temporarily, and clearing the flag during that
// window lets xterm's onData process duplicated input.
if (!_textarea.classList.contains('cjk-input-visible')) {
window.cjkActive = false;
}
// Restore phantom if textarea was emptied while blurred
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
// Reset composing state — some IMEs fire compositionstart without a
// matching compositionend, leaving _composing stuck true and blocking
// all subsequent input events.
_composing = false;
};
_listeners.blur = () => { window.cjkActive = false; };
_textarea.addEventListener('mousedown', _listeners.mousedown);
_textarea.addEventListener('focus', _listeners.focus);
_textarea.addEventListener('blur', _listeners.blur);
_listeners.xtermFocusRedirect = () => {
if (!_isMobileComposer()) return;
_textarea.focus();
};
if (_terminalContainer) {
_xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea');
if (_xtermTextarea) {
_xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
}
}
// ── Composition tracking ──
// ── Composition tracking (keyboard IME — works for CJK typing) ──
_listeners.compositionstart = () => {
_composing = true;
if (_isMobileComposer()) {
if (_textarea.value === PHANTOM) _textarea.value = '';
return;
}
// Clear phantom so IME sees a clean textarea — some IMEs include
// existing text in the composition region which would corrupt input.
if (_textarea.value === PHANTOM) {
_textarea.value = '';
}
_cancelDebouncedFlush();
// Leave textarea.value untouched — programmatic changes during
// compositionstart cancel the IME composition on iOS Safari.
};
_listeners.compositionend = () => {
_composing = false;
if (_isMobileComposer()) return;
_cancelDebouncedFlush();
// Defer flush: some Android IMEs haven't committed text to textarea
// when compositionend fires. setTimeout(0) ensures we read the final value.
setTimeout(_flush, 0);
@@ -172,56 +190,40 @@ const CjkInput = (() => {
// ── Keydown: special keys work REGARDLESS of composition state ──
_listeners.keydown = (e) => {
// Enter: flush accumulated text (or bare Enter if empty).
// No isComposing guard — Android IMEs set isComposing=true for English
// prediction, but Enter should ALWAYS send. We preventDefault to stop
// the IME from also handling Enter (which could double-send or do nothing).
if (e.key === 'Enter') {
e.preventDefault();
_composing = false;
_cancelDebouncedFlush();
const val = _strip(_textarea.value);
if (val) {
_send(val + '\r');
} else {
_send('\r');
}
_resetInput();
_resetToPhantom();
return;
}
// Escape: clear textarea (always works)
if (e.key === 'Escape') {
e.preventDefault();
_composing = false;
_resetInput();
_cancelDebouncedFlush();
_resetToPhantom();
return;
}
// Ctrl combos: forward to PTY (always works)
if (e.ctrlKey && CTRL_KEYS[e.key]) {
e.preventDefault();
_send(CTRL_KEYS[e.key]);
return;
}
// Below: only when NOT composing (composing keystrokes belong to IME)
if (_composing) return;
if (_isMobileComposer()) {
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
e.preventDefault();
_send('\x7f');
return;
}
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
e.preventDefault();
_send(PASSTHROUGH_KEYS[e.key]);
}
return;
}
// Below: only when NOT composing (composing keystrokes belong to IME).
// Also check isComposing/keyCode 229 — the first keydown of a CJK
// sequence arrives BEFORE compositionstart, so _composing is still false.
if (_composing || e.isComposing || e.keyCode === 229) return;
// Backspace: forward to PTY when no real text in textarea
// (Desktop path — Android uses the input event + phantom approach)
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
e.preventDefault();
_send('\x7f');
@@ -236,62 +238,62 @@ const CjkInput = (() => {
return;
}
// Single printable character: send immediately to PTY
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
// Single printable character: send immediately to PTY.
// Third-party IMEs on iOS may ignore preventDefault, so the char
// still enters the textarea and fires an input event — _keydownSentAt
// tells the input handler to skip that echo.
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
e.preventDefault();
_send(e.key);
_keydownSentAt = performance.now();
_resetToPhantom();
return;
}
};
_textarea.addEventListener('keydown', _listeners.keydown);
// ── Input event: the primary path for Android virtual keyboards ──
// Android sends keyCode 229 + key "Unidentified" for virtual key presses,
// making keydown unreliable. input fires AFTER character insertion and
// carries inputType which tells us whether the text is final or tentative.
// ── Input event: primary path for virtual keyboards + dictation ──
_listeners.input = (e) => {
if (_isMobileComposer()) {
if (_textarea.value.includes(PHANTOM)) {
_textarea.value = _strip(_textarea.value);
}
return;
}
// ── Backspace / delete detection ──
// Android long-press backspace generates rapid deleteContentBackward events.
// The phantom character ensures the textarea is never truly empty, so each
// press/repeat fires an input event that we can catch here.
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
if (_composing) return;
if (_isEffectivelyEmpty()) {
// No real text left — forward backspace to PTY
_cancelDebouncedFlush();
_send('\x7f');
_resetToPhantom();
return;
}
// User is editing their own text in the textarea — let it be.
// Ensure phantom is still present for the NEXT backspace.
// Delete on non-empty text outside composition = dictation rewrite.
// The IME is revising provisional text — switch to long debounce.
_enterDictationMode();
if (!_textarea.value.startsWith(PHANTOM)) {
_textarea.value = PHANTOM + _textarea.value;
_textarea.setSelectionRange(1, 1);
}
_debouncedFlush();
return;
}
if (_composing) {
// insertText during composition = IME committed final text
// (e.g., punctuation key inserts 。directly, or IME confirms a word).
// Flush immediately — this text won't change.
if (e.inputType === 'insertText') {
_flush();
return;
}
// insertCompositionText = IME is still working (pinyin, candidates,
// English prediction). Wait for compositionend to flush.
// insertReplacementText = dictation/autocorrect refinement
if (e.inputType === 'insertReplacementText') {
_enterDictationMode();
_debouncedFlush();
return;
}
// Outside composition: send immediately
_flush();
if (_composing) return;
// Keydown handler already sent this character — just clear the
// textarea echo that the IME inserted despite preventDefault.
if (performance.now() - _keydownSentAt < 100) {
_resetToPhantom();
return;
}
// Outside composition: keyboard typing or voice dictation.
// If dictation mode was detected (delete/replacement events seen
// recently), use long debounce. Otherwise short debounce for keyboard.
_debouncedFlush();
};
_textarea.addEventListener('input', _listeners.input);
@@ -300,18 +302,16 @@ const CjkInput = (() => {
},
destroy() {
_cancelDebouncedFlush();
clearTimeout(_dictationDecayTimer);
_dictationActive = false;
if (_textarea) {
for (const [event, handler] of Object.entries(_listeners)) {
if (handler) _textarea.removeEventListener(event, handler);
}
}
if (_xtermTextarea && _listeners.xtermFocusRedirect) {
_xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
}
window.cjkActive = false;
_composing = false;
_terminalContainer = null;
_xtermTextarea = null;
for (const key of Object.keys(_listeners)) delete _listeners[key];
_initialized = false;
},
+11 -7
View File
@@ -4,10 +4,10 @@
* Defines two exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss.
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
*
@@ -129,7 +129,7 @@ const KeyboardAccessoryBar = {
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
if (refocusActions.has(action) ||
(action === 'clear' && this._confirmAction)) {
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
}
@@ -192,11 +192,12 @@ const KeyboardAccessoryBar = {
case 'init':
this.sendCommand('/init');
break;
case 'clear': {
// Require double-tap: first tap turns amber, second tap within 2s sends
case 'clear':
case 'compact': {
const cmd = action === 'clear' ? '/clear' : '/compact';
if (this._confirmAction === action && this._confirmTimer) {
this.clearConfirm();
this.sendCommand('/clear');
this.sendCommand(cmd);
} else {
this.setConfirm(action, btn);
}
@@ -300,7 +301,10 @@ const KeyboardAccessoryBar = {
const sendText = () => {
const text = textarea.value;
close();
if (text) app.sendInput(text);
if (text) {
app.sendInput(text);
setTimeout(() => app.sendInput('\r'), 80);
}
};
// Filter to images, close the dialog, and hand off to the shared
+48 -43
View File
@@ -291,57 +291,60 @@ const KeyboardHandler = {
updateLayoutForKeyboard() {
if (!window.visualViewport) return;
// Only adjust on mobile
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
if (!MobileDetection.isTouchDevice()) {
this.resetLayout();
return;
}
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen();
if (this.keyboardVisible) {
// Calculate how far the toolbar (position:fixed, bottom:0) needs to
// translate up so it sits at the bottom of the visual viewport.
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
// when the user types in xterm's hidden textarea.
//
// MUST measure against the LAYOUT viewport (window.innerHeight): the
// bars are position:fixed, which anchors to the layout viewport — on
// iOS that keeps its full height while the keyboard is open. Measuring
// the shrunken .app instead (its height tracks --app-height = visual
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
// and accessory bar behind the OS keyboard (0.9.8 regression). On
// Android the layout viewport itself shrinks with the keyboard, so
// innerHeight === visualBottom and the offset is naturally 0 there.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
// Move toolbar and accessory bar above keyboard.
// When keyboardOffset is 0 (viewport scrolled to layout bottom),
// the bars are naturally positioned via their CSS bottom values —
// just clear the transforms. Never dismiss keyboard state here;
// that's handleViewportResize's job.
if (toolbar) {
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
// Reserve only Codeman's visible controls. The OS keyboard is outside
// the visual viewport; adding its height here creates a large blank area
// above the mobile toolbar on iPhone.
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
if (main && keyboardHeight > 0) {
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
if (isSmallMedium) {
// Phones/small tablets: toolbar and accessory bar are position:fixed
// via CSS. Use translateY to lift them above the keyboard.
const toolbar = document.querySelector('.toolbar');
const main = document.querySelector('.main');
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
if (toolbar) {
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (main && keyboardHeight > 0) {
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
}
} else if (keyboardHeight > 0) {
// iPad: use direct bottom positioning (translateY unreliable —
// iOS auto-scrolls the visual viewport, making keyboardOffset ≈ 0).
if (accessoryBar) {
accessoryBar.style.bottom = `${keyboardHeight}px`;
}
}
// CJK textarea positioning (always position:fixed on touch devices).
if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
if (isSmallMedium) {
// Phones: use translateY like toolbar/accessory bar.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
cjkInput.style.bottom = '';
} else {
// iPad: direct bottom = keyboard + accessory bar height.
cjkInput.style.bottom = `${keyboardHeight + 44}px`;
cjkInput.style.transform = '';
}
}
} else {
this.resetLayout();
@@ -360,9 +363,11 @@ const KeyboardHandler = {
}
if (accessoryBar) {
accessoryBar.style.transform = '';
accessoryBar.style.bottom = '';
}
if (cjkInput) {
cjkInput.style.transform = '';
cjkInput.style.bottom = '';
}
if (main) {
main.style.paddingBottom = '';
+4 -168
View File
@@ -1065,85 +1065,7 @@ html.mobile-init .file-browser-panel {
}
/* Paste overlay for iOS clipboard access */
.paste-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.paste-dialog {
background: var(--bg-secondary, #1e1e2e);
border: 1px solid var(--border-color, #444);
border-radius: 12px;
padding: 12px;
width: calc(100% - 24px);
max-width: 400px;
}
.paste-textarea {
width: 100%;
min-height: 80px;
max-height: 200px;
background: var(--bg-primary, #0d0d14);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #444);
border-radius: 8px;
padding: 8px;
font-family: inherit;
font-size: 16px;
resize: none;
box-sizing: border-box;
}
.paste-textarea:focus {
outline: none;
border-color: var(--accent-color, #7aa2f7);
}
.paste-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.paste-cancel, .paste-new, .paste-send, .paste-image {
padding: 8px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
/* Image attach button — left-aligned, accent outline */
.paste-image {
margin-right: auto;
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-cancel {
background: var(--bg-tertiary, #333);
color: var(--text-secondary, #aaa);
}
.paste-new {
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-send {
background: var(--accent-color, #7aa2f7);
color: #fff;
font-weight: 600;
}
/* Paste overlay styles extracted to universal section below (line ~2293+) */
/* LEGACY: Hide old toolbar select (no longer used on mobile) */
.toolbar-select {
@@ -2177,95 +2099,9 @@ html.mobile-init .file-browser-panel {
}
/* ============================================================================
Keyboard Accessory Bar — all mobile/tablet sizes
Visual styles extracted from phone breakpoint so they apply on iPad too.
Phone-specific positioning (position: fixed) remains in @media (max-width: 430px).
============================================================================ */
.keyboard-accessory-bar {
display: none;
height: 44px;
background: #1a1a1a;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 8px;
gap: 8px;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
z-index: 51;
}
.keyboard-accessory-bar.visible {
display: flex;
}
.keyboard-accessory-bar::-webkit-scrollbar {
display: none;
}
.accessory-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 4px;
padding: 6px 12px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e5e5e5;
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.accessory-btn.confirming {
background: #6b4f00;
border-color: #b8860b;
color: #ffd54f;
}
.accessory-btn:active {
background: #3a3a3a;
}
.accessory-btn svg {
width: 14px;
height: 14px;
}
.accessory-btn-arrow {
padding: 6px 10px;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
}
.accessory-btn-arrow:active {
background: #1d4ed8;
}
.accessory-btn-dismiss {
margin-left: auto;
flex: 1 1 0;
max-width: 80px;
padding: 10px 8px;
background: #334d6e;
border-color: rgba(100, 150, 200, 0.4);
color: #c0d4e8;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 20px;
height: 20px;
}
.accessory-btn-dismiss:active {
background: #3d5f85;
}
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
(always loaded — covers iPad landscape where mobile.css doesn't load).
Phone-specific overrides remain in @media (max-width: 430px) above. */
/* ============================================================================
iOS Safari Specific Fixes
+10 -1
View File
@@ -318,6 +318,7 @@ Object.assign(CodemanApp.prototype, {
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -1417,6 +1418,7 @@ Object.assign(CodemanApp.prototype, {
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -1734,6 +1736,7 @@ Object.assign(CodemanApp.prototype, {
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
showRedrawButton: false,
// Input
gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile
@@ -1873,6 +1876,12 @@ Object.assign(CodemanApp.prototype, {
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
}
const showRedrawButton = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
const redrawBtn = document.querySelector('.btn-redraw-terminal');
if (redrawBtn) {
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
}
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
const notifBtn = document.querySelector('.btn-notifications');
@@ -2106,7 +2115,7 @@ Object.assign(CodemanApp.prototype, {
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
+264 -12
View File
@@ -996,6 +996,12 @@ body {
display: none !important;
}
/* Redraw terminal header button: hidden by default (opt-in via App Settings →
Display → "Header Displays"). Pure client-side toggle. */
.btn-redraw-terminal--hidden {
display: none !important;
}
.btn-icon-header.btn-settings {
width: 30px;
height: 30px;
@@ -2229,6 +2235,34 @@ body.solo-mode .btn-lifecycle-log {
background: transparent !important;
}
/* Touch devices: prevent browser from claiming the touch gesture before
our JS touchmove handler fires. Without this, the browser starts native
scrolling during the first few px of finger travel and ignores our
subsequent preventDefault() — breaking custom terminal scroll. */
.touch-device .terminal-container,
.touch-device .terminal-container .xterm,
.touch-device .terminal-container .xterm-viewport,
.touch-device .terminal-container .xterm-screen {
touch-action: none;
}
/* Mobile terminal taps are cursor-positioning gestures, not text-selection gestures.
xterm re-enables selectable DOM text in its accessibility tree, so force the
whole terminal subtree back to non-selectable on touch devices. Synthetic mouse
events for tap-to-position still dispatch normally through JavaScript. */
body.touch-device .terminal-container .xterm,
body.touch-device .terminal-container .xterm .xterm-viewport,
body.touch-device .terminal-container .xterm .xterm-screen,
body.touch-device .terminal-container .xterm .xterm-rows,
body.touch-device .terminal-container .xterm .xterm-accessibility-tree,
body.touch-device .terminal-container .xterm .xterm-accessibility-tree *,
body.touch-device .terminal-container .xterm .xterm-selection,
body.touch-device .terminal-container .xterm .xterm-helper-textarea {
user-select: none !important;
-webkit-user-select: none !important;
-webkit-touch-callout: none !important;
}
/* Welcome Overlay */
.welcome-overlay {
position: absolute;
@@ -2719,6 +2753,13 @@ body.solo-mode .btn-lifecycle-log {
contain: style;
}
/* backdrop-filter creates a stacking context, trapping the popover's
z-index inside the toolbar. When the popover is open, raise the toolbar
above the CJK input (z-index 52) so the popover is interactable. */
.toolbar:has(.case-settings-popover:not(.hidden)) {
z-index: 100;
}
.toolbar-left,
.toolbar-right {
display: flex;
@@ -3007,6 +3048,11 @@ body.solo-mode .btn-lifecycle-log {
white-space: nowrap;
}
/* Touch devices (iPad, tablets): show stop button — no physical Ctrl+C */
.touch-device .btn-toolbar.btn-stop {
display: flex;
}
/* Mobile app settings gear - hidden on desktop, shown on mobile */
.btn-settings-mobile {
display: none;
@@ -8427,14 +8473,14 @@ kbd {
flex-shrink: 0;
}
.ultracode-status.completed {
background: var(--success);
background: var(--green);
}
.ultracode-status.active {
background: var(--warning);
background: var(--yellow);
color: black;
}
.ultracode-status.failed {
background: var(--error, #b3261e);
background: var(--red, #b3261e);
color: white;
}
@@ -8485,6 +8531,16 @@ kbd {
border-radius: 6px;
padding: 0.4rem 0.5rem;
margin-bottom: 0.35rem;
border-left: 3px solid transparent;
}
/* At-a-glance agent state: yellow while working, green when done. */
.ultracode-agent-card.uw-state-working {
border-left-color: var(--yellow);
background: color-mix(in srgb, var(--yellow) 9%, var(--bg-input));
}
.ultracode-agent-card.uw-state-done {
border-left-color: var(--green);
background: color-mix(in srgb, var(--green) 9%, var(--bg-input));
}
.ultracode-agent-card--clickable {
cursor: pointer;
@@ -8493,6 +8549,14 @@ kbd {
.ultracode-agent-card--clickable:hover {
background: var(--bg-card);
}
/* Keep the state tint on hover (equal specificity to the hover rule, so it must
come after it) — a tad stronger so the hover still reads as interactive. */
.ultracode-agent-card--clickable.uw-state-working:hover {
background: color-mix(in srgb, var(--yellow) 16%, var(--bg-card));
}
.ultracode-agent-card--clickable.uw-state-done:hover {
background: color-mix(in srgb, var(--green) 16%, var(--bg-card));
}
.ultracode-agent-top {
display: flex;
align-items: center;
@@ -8518,10 +8582,10 @@ kbd {
color: white;
}
.ultracode-agent-state.completed {
background: var(--success);
background: var(--green);
}
.ultracode-agent-state.active {
background: var(--warning);
background: var(--yellow);
color: black;
}
.ultracode-agent-state.idle {
@@ -8661,14 +8725,15 @@ kbd {
margin-bottom: 0.4rem;
}
/* Ultracode connector line — distinct purple to set it apart from blue subagent lines. */
/* Ultracode connector line — the session-tab accent blue (dashed to stay distinct
from the solid subagent lines, per the same-blue-as-the-tab request). */
.connection-line.ultracode-connection {
stroke: #a855f7;
stroke: var(--accent);
stroke-width: 3;
stroke-dasharray: 6 3;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 5px rgba(168, 85, 247, 0.85))
drop-shadow(0 0 10px rgba(168, 85, 247, 0.5));
drop-shadow(0 0 5px rgba(var(--accent-rgb), 0.85))
drop-shadow(0 0 10px rgba(var(--accent-rgb), 0.5));
animation: ultracode-conn-pulse 1.4s ease-in-out infinite;
}
.connection-line.ultracode-connection:hover {
@@ -9307,9 +9372,9 @@ kbd {
bottom: calc(var(--safe-area-bottom) + 40px);
z-index: 52;
display: block;
min-height: 44px;
max-height: 96px;
padding: 12px 10px;
min-height: 34px;
max-height: 68px;
padding: 6px 10px;
border: 1px solid rgba(80, 120, 190, 0.55);
border-left: none;
border-right: none;
@@ -9328,6 +9393,193 @@ body.touch-device.cjk-input-visible .main {
padding-bottom: calc(84px + var(--safe-area-bottom));
}
/* ═══════════════════════════════════════════════════════════════
Keyboard Accessory Bar
Base styles in styles.css (always loaded) so iPad landscape (≥1024px,
where mobile.css doesn't load) still gets dark styling. Phone overrides
remain in mobile.css @media (max-width: 430px).
═══════════════════════════════════════════════════════════════ */
.keyboard-accessory-bar {
display: none;
height: 44px;
background: #1a1a1a;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 8px;
gap: 8px;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
z-index: 51;
}
.keyboard-accessory-bar.visible {
display: flex;
}
.keyboard-accessory-bar::-webkit-scrollbar {
display: none;
}
/* Touch devices: fixed positioning to float above keyboard.
Phone CSS (mobile.css) adds bottom + safe-area padding;
iPad JS sets bottom dynamically via updateLayoutForKeyboard(). */
.touch-device .keyboard-accessory-bar {
position: fixed;
left: 0;
right: 0;
transition: transform 0.15s ease-out;
will-change: transform;
}
.accessory-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 4px;
padding: 6px 12px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e5e5e5;
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.accessory-btn.confirming {
background: #6b4f00;
border-color: #b8860b;
color: #ffd54f;
}
.accessory-btn:active {
background: #3a3a3a;
}
.accessory-btn svg {
width: 14px;
height: 14px;
}
.accessory-btn-arrow {
padding: 6px 10px;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
}
.accessory-btn-arrow:active {
background: #1d4ed8;
}
.accessory-btn-dismiss {
margin-left: auto;
flex: 1 1 0;
max-width: 100px;
padding: 10px 8px;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 22px;
height: 22px;
}
.accessory-btn-dismiss:active {
background: #1d4ed8;
}
/* ═══════════════════════════════════════════════════════════════
Paste Overlay
Base styles in styles.css so iPad landscape works.
═══════════════════════════════════════════════════════════════ */
.paste-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.paste-dialog {
background: var(--bg-secondary, #1e1e2e);
border: 1px solid var(--border-color, #444);
border-radius: 12px;
padding: 12px;
width: calc(100% - 24px);
max-width: 400px;
}
.paste-textarea {
width: 100%;
min-height: 80px;
max-height: 200px;
background: var(--bg-primary, #0d0d14);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #444);
border-radius: 8px;
padding: 8px;
font-family: inherit;
font-size: 16px;
resize: none;
box-sizing: border-box;
}
.paste-textarea:focus {
outline: none;
border-color: var(--accent-color, #7aa2f7);
}
.paste-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.paste-cancel, .paste-new, .paste-send, .paste-image {
padding: 8px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.paste-image {
margin-right: auto;
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-cancel {
background: var(--bg-tertiary, #333);
color: var(--text-secondary, #aaa);
}
.paste-new {
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-send {
background: var(--accent-color, #7aa2f7);
color: #fff;
font-weight: 600;
}
/* ═══════════════════════════════════════════════════════════════
Orchestrator Panel
═══════════════════════════════════════════════════════════════ */
+121 -79
View File
@@ -19,6 +19,10 @@
// suppressed, so high-frequency Codex status redraws don't snap the viewport
// back to the bottom while the user is inspecting earlier output.
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
// Mobile browsers synthesize trusted mouse events after touchend. During this
// short window, only the app's synthetic tap-to-position mouse event should
// reach xterm.
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
@@ -46,6 +50,7 @@
isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse,
USER_SCROLL_STICKY_SUPPRESS_MS,
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
@@ -107,6 +112,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._installMobileTapMouseGuard();
// Suppress xterm key handling during CJK IME composition.
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
@@ -365,11 +371,14 @@ Object.assign(CodemanApp.prototype, {
let pixelAccum = 0;
let didScroll = false; // track whether touchmove fired (tap vs scroll)
let touchStartY = 0;
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
container.addEventListener(
'touchstart',
(ev) => {
if (ev.touches.length === 1) {
touchLastY = ev.touches[0].clientY;
touchStartY = touchLastY;
velocity = 0;
pixelAccum = 0;
isTouching = true;
@@ -388,9 +397,18 @@ Object.assign(CodemanApp.prototype, {
'touchmove',
(ev) => {
if (ev.touches.length === 1 && isTouching) {
ev.preventDefault();
didScroll = true;
const touchY = ev.touches[0].clientY;
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
didScroll = true;
}
// Below the tap threshold, treat the gesture as a potential tap:
// don't preventDefault (iOS needs click synthesis to show the
// keyboard) and don't accumulate scroll distance or velocity. Without
// this guard, sub-threshold micro-drift still scrolls a line and
// leaves a non-zero velocity that touchend turns into a momentum
// fling, so a jittery tap would both position the cursor AND scroll.
if (!didScroll) return;
ev.preventDefault();
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
velocity = delta * 1.2;
@@ -410,20 +428,38 @@ Object.assign(CodemanApp.prototype, {
container.addEventListener(
'touchend',
() => {
(ev) => {
isTouching = false;
if (!scrollFrame && Math.abs(velocity) > 0.3) {
scrollFrame = requestAnimationFrame(scrollLoop);
}
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
// routes back to the terminal. Without this, a tap on the terminal area
// consumes the touch event but xterm's textarea never regains focus.
if (!didScroll && this.terminal) {
// ── Tap-to-position cursor ──────────────────────────────────
// Synthesize a click from the real touch point so the foreground app
// moves its cursor to the tapped cell (iOS doesn't reliably do this
// itself under touch-action:none). CRITICAL: only when mouse tracking
// is ON. xterm disables its local SelectionService while mouse events
// are active, so the synthetic click is forwarded to the PTY as an SGR
// report (cursor moves). But when tracking is OFF, that same click
// drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a
// tap on CJK text would select & copy it instead of positioning. So
// gate strictly on the live mouse-tracking mode.
const touch = ev.changedTouches && ev.changedTouches[0];
const mouseMode = this.terminal.modes?.mouseTrackingMode;
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
if (touch) {
this._suppressTrustedTapMouseEvents();
}
if (touch && mouseTrackingOn) {
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
}
this._syncMobileHelperTextareaToCursor();
// Route subsequent typing to the right place: keep the CJK input
// field focused when Chinese input is on, otherwise the terminal.
const cjkInput = document.getElementById('cjkInput');
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.focus();
} else {
this._syncMobileHelperTextareaToCursor();
this.terminal.focus();
}
}
@@ -585,8 +621,13 @@ Object.assign(CodemanApp.prototype, {
// survives tab switches and reconnects.
this.terminal.onData((data) => {
// CJK input has focus — block xterm from sending to PTY
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
// 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
// is on, because cjkActive stays true the whole time the field is visible.
const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data);
// CJK input has focus — block xterm from sending keystrokes to PTY
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return;
if (this.activeSessionId) {
// Filter terminal query replies generated by xterm.js itself.
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
@@ -1577,71 +1618,11 @@ Object.assign(CodemanApp.prototype, {
}
},
// CJK textarea already provides visual feedback — bypass local echo
// buffering so each composed word reaches the PTY immediately.
_handleCjkInput(text) {
if (!this.activeSessionId) return;
const sessionId = this.activeSessionId;
const session = this.sessions.get(sessionId);
const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell');
if (!useLocalEcho) {
this._sendInputAsync(sessionId, text);
return;
}
if (text === '\x7f') {
const source = this._localEchoOverlay.removeChar();
if (source === 'flushed') {
// Sync app-level flushed Maps (per-session state for tab switching),
// mirroring the onData backspace path — otherwise switching tabs away
// and back restores a stale, too-long flushed overlay.
const { count, text: flushedText } = this._localEchoOverlay.getFlushed();
if (this._flushedOffsets?.has(sessionId)) {
if (count === 0) {
this._flushedOffsets.delete(sessionId);
this._flushedTexts?.delete(sessionId);
} else {
this._flushedOffsets.set(sessionId, count);
this._flushedTexts?.set(sessionId, flushedText);
}
}
this._sendInputAsync(sessionId, text);
}
return;
}
if (/[\r\n]+$/.test(text)) {
const committed = text.replace(/[\r\n]+$/g, '');
if (committed) this._localEchoOverlay.appendText(committed);
const pending = this._localEchoOverlay.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (pending) this._sendInputAsync(sessionId, pending);
setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0);
return;
}
// Multi-byte escape sequence (arrow/Home/End from a hardware keyboard on
// the composer) — forward to the PTY without touching overlay state,
// mirroring the onData path. Appending it to pending text would type raw
// ESC bytes into the prompt on the next Enter.
if (text.length > 1 && text.charCodeAt(0) === 27) {
this._sendInputAsync(sessionId, text);
return;
}
if (text.length === 1 && text.charCodeAt(0) < 32) {
const pending = this._localEchoOverlay.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (pending) this._sendInputAsync(sessionId, pending);
this._sendInputAsync(sessionId, text);
return;
}
this._localEchoOverlay.appendText(text);
this._sendInputAsync(this.activeSessionId, text);
},
/**
@@ -1995,9 +1976,10 @@ Object.assign(CodemanApp.prototype, {
}
try {
// Send resize to restore proper dimensions (with minimum enforcement).
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
await this.sendResize(this.activeSessionId);
// Force resize even when dimensions match the server's last known state —
// another device may have changed the PTY size since this client last sent,
// and force guarantees a SIGWINCH → Ink redraw at the current device's size.
await this.sendResize(this.activeSessionId, { force: true });
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) {
@@ -2054,6 +2036,62 @@ Object.assign(CodemanApp.prototype, {
} catch {}
},
// ═══════════════════════════════════════════════════════════════
// Synthetic tap → mouse report
// ═══════════════════════════════════════════════════════════════
// Dispatch a mousedown+mouseup pair at viewport coords (clientX/clientY) to
// xterm's root element. xterm's mouse-reporting handler reads the event's
// client coords, maps them to a terminal cell relative to .xterm-screen, and
// — when the foreground app has mouse tracking active (DECSET 1000/1002/1006,
// which Claude's input enables) — encodes an SGR mouse report to the PTY.
// That is the same path a real desktop click takes; on touch devices the
// browser's own compatibility-event synthesis is unreliable (and suppressed
// by touch-action:none), so we drive it explicitly. With mouse tracking off
// it degrades to a harmless zero-length click (no drag → no text selection).
_dispatchSyntheticTerminalClick(clientX, clientY) {
const el = this.terminal?.element;
if (!el || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
// xterm registers its mouseup listener on document during mousedown, so a
// bubbling mouseup reaches it; dispatch both to the root element in order.
const base = {
bubbles: true,
cancelable: true,
view: window,
clientX,
clientY,
screenX: clientX,
screenY: clientY,
button: 0,
detail: 1,
};
try {
el.dispatchEvent(new MouseEvent('mousedown', { ...base, buttons: 1 }));
el.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
} catch {
/* MouseEvent constructor unavailable — tap-to-position simply no-ops */
}
},
_installMobileTapMouseGuard() {
const el = this.terminal?.element;
if (!el || el._codemanTapMouseGuardInstalled) return;
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice && !MobileDetection.isTouchDevice()) return;
el._codemanTapMouseGuardInstalled = true;
const suppressTrustedCompatMouse = (ev) => {
const suppressUntil = this._trustedTapMouseSuppressUntil || 0;
if (!ev.isTrusted || performance.now() > suppressUntil) return;
ev.preventDefault();
ev.stopImmediatePropagation();
};
el.addEventListener('mousedown', suppressTrustedCompatMouse, true);
el.addEventListener('mouseup', suppressTrustedCompatMouse, true);
},
_suppressTrustedTapMouseEvents() {
const ms = window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450;
this._trustedTapMouseSuppressUntil = performance.now() + ms;
},
increaseFontSize() {
const current = this.terminal.options.fontSize || 14;
this.setFontSize(Math.min(current + 2, 24));
@@ -2103,8 +2141,8 @@ Object.assign(CodemanApp.prototype, {
/**
* Send resize to a session with minimum dimension enforcement.
* @param {string} sessionId
* @param {{ forceHttp?: boolean }} [options]
* @returns {Promise<void>}
* @param {{ forceHttp?: boolean, force?: boolean }} [options]
* @returns {Promise<boolean>} Whether dimensions changed from the last send
*/
async sendResize(sessionId, options = {}) {
// Fit terminal to container before reading dimensions — ensures local
@@ -2133,16 +2171,20 @@ Object.assign(CodemanApp.prototype, {
// Fast path: WebSocket resize
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
try {
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType }));
const msg = { t: 'z', c: dims.cols, r: dims.rows, v: viewportType };
if (options.force) msg.f = true;
this._ws.send(JSON.stringify(msg));
return changed;
} catch {
// Fall through to HTTP POST
}
}
const body = { ...dims, viewportType };
if (options.force) body.force = true;
await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dims, viewportType }),
body: JSON.stringify(body),
});
return changed;
},
+15 -2
View File
@@ -96,6 +96,10 @@ Object.assign(CodemanApp.prototype, {
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
this._fetchWorkflowRunDetail(runId);
this.renderUltracodeAgentsPanel();
// Clicking a run also pops its floating window (with connector line to the
// session tab), like the auto-popped one — an explicit open, so it ignores the
// floating-windows auto-pop toggle (ultracode-windows.js).
if (typeof this.openUltracodeWindowForRun === 'function') this.openUltracodeWindowForRun(runId);
},
selectWorkflowPhase(phaseIndex) {
this._ensureWorkflowState();
@@ -105,6 +109,11 @@ Object.assign(CodemanApp.prototype, {
},
async _fetchWorkflowRunDetail(runId) {
// De-dupe concurrent fetches for the same run — selecting a run can trigger both
// a panel refresh and a floating-window open, which would otherwise double-fetch.
if (!this._wfDetailInFlight) this._wfDetailInFlight = new Set();
if (this._wfDetailInFlight.has(runId)) return;
this._wfDetailInFlight.add(runId);
try {
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
const env = await res.json();
@@ -117,6 +126,8 @@ Object.assign(CodemanApp.prototype, {
}
} catch {
/* transient — next update retries */
} finally {
this._wfDetailInFlight.delete(runId);
}
},
@@ -274,10 +285,12 @@ Object.assign(CodemanApp.prototype, {
// to the agent-<id>.jsonl stem already tracked by subagent-watcher). 'start' agents have
// no agentId yet, so they stay non-clickable.
const clickable = !!a.agentId;
// At-a-glance state tint on the whole card: green when done, yellow while working.
const cardStateCls = state === 'done' ? ' uw-state-done' : state === 'progress' ? ' uw-state-working' : '';
const cardAttrs = clickable
? ` class="ultracode-agent-card ultracode-agent-card--clickable" role="button" tabindex="0"` +
? ` class="ultracode-agent-card ultracode-agent-card--clickable${cardStateCls}" role="button" tabindex="0"` +
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
: ` class="ultracode-agent-card"`;
: ` class="ultracode-agent-card${cardStateCls}"`;
return (
`<div${cardAttrs}>` +
`<div class="ultracode-agent-top">` +
+29 -2
View File
@@ -77,12 +77,14 @@ Object.assign(CodemanApp.prototype, {
_syncUltracodeFloatingWindow(run, opts) {
this._ensureUltracodeWindowState();
if (!run || !run.runId) return;
if (!this._ultracodeFloatingEnabled()) return;
const runId = run.runId;
const existing = this.ultracodeWindows.get(runId);
// Auto-pop is gated on the floating-windows toggle, but an ALREADY-open window
// (e.g. one opened by clicking the run in the dock) keeps refreshing regardless.
if (!existing && !this._ultracodeFloatingEnabled()) return;
if (this.ultracodeWindowsClosed.has(runId)) return; // respect explicit dismissal
const active = this._isWorkflowRunActive(run);
const existing = this.ultracodeWindows.get(runId);
if (active) {
// Run is alive — cancel any pending auto-close.
@@ -120,6 +122,31 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Explicitly open (or focus) the floating window for a run — the click-through
* from the dock panel's run list. Unlike auto-pop this ignores the floating-windows
* toggle and clears any prior dismissal (it's a direct user action), then draws the
* connector line from the run's session tab.
*/
openUltracodeWindowForRun(runId) {
this._ensureUltracodeWindowState();
if (!runId) return;
const run = this.workflowRuns && this.workflowRuns.get(runId);
if (!run) return;
this.ultracodeWindowsClosed.delete(runId); // an explicit open overrides a past dismissal
const existing = this.ultracodeWindows.get(runId);
if (existing) {
// Already open — bring to front, expand if collapsed, refresh.
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
if (existing.collapsed) this.toggleUltracodeWindowCollapse(runId);
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId);
this.updateConnectionLines();
} else {
this.createUltracodeWindow(run);
}
},
/** Build and mount a floating window for a run, positioned near its parent tab. */
createUltracodeWindow(run) {
this._ensureUltracodeWindowState();
+2 -6
View File
@@ -746,14 +746,10 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/resize', async (req) => {
const { id } = req.params as { id: string };
const { cols, rows, viewportType } = parseBody(ResizeSchema, req.body);
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
const session = findSessionOrFail(ctx, id);
if (viewportType) {
session.resize(cols, rows, { viewportType });
} else {
session.resize(cols, rows);
}
session.resize(cols, rows, { viewportType, force });
return {};
});
+3 -6
View File
@@ -23,7 +23,7 @@
* {"t":"r"} — needs refresh (reload buffer)
* Client -> Server:
* {"t":"i","d":"..."} — input (keystroke or paste)
* {"t":"z","c":N,"r":N} — resize terminal
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
*/
import { FastifyInstance } from 'fastify';
@@ -146,11 +146,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
session.releaseDesktopSizing(sizingToken);
holdsDesktopClaim = false;
}
if (viewportType) {
session.resize(msg.c, msg.r, { viewportType });
} else {
session.resize(msg.c, msg.r);
}
const force = msg.f === true;
session.resize(msg.c, msg.r, { viewportType, force });
}
} catch {
// Ignore malformed messages
+2
View File
@@ -186,6 +186,7 @@ export const ResizeSchema = z.object({
cols: z.number().int().min(1).max(500),
rows: z.number().int().min(1).max(200),
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
force: z.boolean().optional(),
});
/**
@@ -384,6 +385,7 @@ export const SettingsUpdateSchema = z
// (client-side), but telemetry COLLECTION is server-side, so the per-device
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
statusLineTelemetry: z.boolean().optional(),
showRedrawButton: z.boolean().optional(),
// Input
gestureControlEnabled: z.boolean().optional(),
// Claude CLI settings
+380 -49
View File
@@ -53,16 +53,99 @@ const RUN_FILE_PREFIX = 'wf_';
const RUN_FILE_SUFFIX = '.json';
const LIVE_JOURNAL_FILE = 'journal.jsonl';
const LIVE_AGENT_PREFIX = 'agent-';
const LIVE_TRANSCRIPT_SUFFIX = '.jsonl';
const SCRIPTS_SUBDIR = 'scripts';
/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */
const PROMPT_PREVIEW_MAX = 200;
const RESULT_PREVIEW_MAX = 240;
/** Cap a live script read so a runaway/huge embedded script can't blow memory. */
const SCRIPT_READ_MAX_BYTES = 512 * 1024;
/** LRU cap on cached per-agent transcript stats across all live runs. */
const MAX_CACHED_AGENT_STATS = 2000;
function truncate(value: string | undefined, max: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.length > max ? `${value.slice(0, max)}…` : value;
}
/** First text from a transcript message's `content` (string or content-block array). */
function extractMessageText(content: unknown): string | undefined {
if (typeof content === 'string') return content.trim() || undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block && typeof block === 'object' && (block as { type?: string }).type === 'text') {
const t = (block as { text?: string }).text;
if (typeof t === 'string' && t.trim()) return t.trim();
}
}
}
return undefined;
}
/**
* Isolate the `meta = { … }` object literal from a workflow script so that later
* declarations (e.g. JSON-Schema objects with their own `description:`/`title:`
* fields, or prose containing `phases: [`) can't be mistaken for meta. Brace-matches
* naively — a `{`/`}` inside a string value can still confuse it, in which case we
* return undefined and the caller falls back to scanning the whole body. Best-effort.
*/
function extractMetaBlock(body: string): string | undefined {
const m = /(?:^|[^A-Za-z0-9_])(?:export\s+const|const|let|var)\s+meta\s*=\s*\{/.exec(body);
if (!m) return undefined;
const open = body.indexOf('{', m.index);
if (open < 0) return undefined;
let depth = 0;
for (let i = open; i < body.length; i++) {
if (body[i] === '{') depth++;
else if (body[i] === '}' && --depth === 0) return body.slice(open, i + 1);
}
return undefined; // unterminated (truncated script) — caller falls back to whole body
}
/**
* Best-effort extraction of a single/double-quoted `key: '...'` value from a workflow
* `meta` literal (the script is JS we must not eval). The key is anchored to a key
* position (`{`/`,`/whitespace before it) so e.g. `description` can't match the tail
* of `..._description`; pass the scoped meta block so later same-named schema fields
* can't win. Returns undefined on no match.
*/
function extractMetaString(body: string, key: string): string | undefined {
const re = new RegExp(`(?:^|[{,\\s])${key}\\s*:\\s*(['"])((?:\\\\.|(?!\\1).)*)\\1`);
const m = re.exec(body);
return m ? m[2].replace(/\\(['"\\`])/g, '$1') : undefined;
}
/** Best-effort `meta.phases: [{title, detail}, …]` extraction (pass the scoped meta block). */
function extractMetaPhases(body: string): WorkflowRunPhase[] {
const keyMatch = /(?:^|[^A-Za-z0-9_])phases\s*:\s*\[/.exec(body);
if (!keyMatch) return [];
const open = body.indexOf('[', keyMatch.index);
if (open < 0) return [];
// Brace-match to the array's closing ] so nested arrays don't end it early.
let depth = 0;
let end = -1;
for (let i = open; i < body.length; i++) {
if (body[i] === '[') depth++;
else if (body[i] === ']' && --depth === 0) {
end = i;
break;
}
}
if (end < 0) return [];
const block = body.slice(open, end + 1);
const phases: WorkflowRunPhase[] = [];
const re = /title\s*:\s*(['"])((?:\\.|(?!\1).)*)\1(?:\s*,\s*detail\s*:\s*(['"])((?:\\.|(?!\3).)*)\3)?/g;
let m: RegExpExecArray | null;
while ((m = re.exec(block)) !== null && phases.length < 20) {
phases.push({
title: m[2].replace(/\\(['"\\`])/g, '$1'),
detail: (m[4] || '').replace(/\\(['"\\`])/g, '$1'),
});
}
return phases;
}
/** Drop the heavy `agents[]` for list/snapshot use. */
export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary {
const { agents: _agents, ...summary } = info;
@@ -85,6 +168,34 @@ interface DiscoveredLiveRun {
runId: string;
}
/** Per-agent stats parsed from one `agent-<id>.jsonl` transcript (mtime-cached). */
interface AgentTranscriptStats {
/** Tokens for the agent's LAST usage-bearing message (in+out+cache) ≈ the completion-JSON `tokens`. */
tokens: number;
/** Count of `tool_use` blocks across the transcript (matches the completion-JSON `toolCalls`). */
toolCalls: number;
/** Most-recent model id seen. */
model: string;
/** Name of the most-recent `tool_use` block. */
lastToolName?: string;
/** First user-message text, truncated — a hint of what the agent was asked. */
promptPreview?: string;
}
/** Workflow meta derived live from `workflows/scripts/<name>-<runId>.js`. */
interface LiveWorkflowMeta {
workflowName?: string;
summary?: string;
phases: WorkflowRunPhase[];
}
/** A live agent file on disk (transcript and/or meta), keyed by its `<id>` stem. */
interface LiveAgentFile {
agentId: string;
transcriptPath?: string;
transcriptMtime?: number;
}
/** A `workflowProgress[]` entry as it appears on disk (loosely typed for defensive parsing). */
interface RawProgressEntry {
type?: string;
@@ -123,6 +234,17 @@ export class WorkflowRunWatcher extends EventEmitter {
private liveDirMtimes = new Map<string, number>();
/** runId -> absolute live transcript-dir path (for mtime cleanup on removal). */
private runIdToLiveDir = new Map<string, string>();
/** transcript abs path -> { mtimeMs, stats }; re-parse a transcript only when its mtime moves. */
private agentStatCache = new LRUMap<string, { mtimeMs: number; stats: AgentTranscriptStats }>({
maxSize: MAX_CACHED_AGENT_STATS,
});
/** runId -> derived script meta (immutable per run; re-derived only until a name is found). */
private liveMetaCache = new Map<string, LiveWorkflowMeta>();
/** journal abs path -> { mtimeMs, parsed }; re-parse the journal only when it grows. */
private journalCache = new Map<
string,
{ mtimeMs: number; parsed: { startedOrder: string[]; doneIds: Set<string> } }
>();
/** watched-dir absolute path -> chokidar watcher (workflows/ + subagents/workflows/). */
private dirWatchers = new Map<string, ChokidarWatcher>();
@@ -160,6 +282,9 @@ export class WorkflowRunWatcher extends EventEmitter {
this.runIdToPath.clear();
this.liveDirMtimes.clear();
this.runIdToLiveDir.clear();
this.agentStatCache.clear();
this.liveMetaCache.clear();
this.journalCache.clear();
}
/** All cached runs (no recency filter), most-recently-active first. */
@@ -243,7 +368,18 @@ export class WorkflowRunWatcher extends EventEmitter {
if (path) this.fileMtimes.delete(path);
this.runIdToPath.delete(runId);
const liveDir = this.runIdToLiveDir.get(runId);
if (liveDir) this.liveDirMtimes.delete(liveDir);
if (liveDir) {
this.liveDirMtimes.delete(liveDir);
// Drop cached transcript stats + journal parse under this run's live dir.
const prefix = liveDir.endsWith('/') ? liveDir : liveDir + '/';
for (const key of Array.from(this.agentStatCache.keys())) {
if (key.startsWith(prefix)) this.agentStatCache.delete(key);
}
for (const key of Array.from(this.journalCache.keys())) {
if (key.startsWith(prefix)) this.journalCache.delete(key);
}
}
this.liveMetaCache.delete(runId);
this.runIdToLiveDir.delete(runId);
this.emit('run_removed', { runId });
}
@@ -346,14 +482,58 @@ export class WorkflowRunWatcher extends EventEmitter {
}
/**
* Re-synthesize an in-flight run from its transcript dir when its newest member
* mtime moved (skip otherwise so we don't re-emit run_updated on idle polls).
* Re-synthesize an in-flight run from its transcript dir. Cheap pass first: stat
* the dir members and skip entirely when the newest mtime is unchanged, so an idle
* poll never reads a single (large) transcript. Only on a real change do we parse —
* and even then per-transcript stats come from an mtime-keyed cache, so only the
* transcripts that actually grew are re-read.
*/
private async maybeParseLive(live: DiscoveredLiveRun): Promise<void> {
const info = await this.parseLiveDir(live);
let entries: string[];
try {
entries = await readdir(live.dirPath);
} catch {
return; // vanished between discover and read
}
// Cheap pass: newest member mtime + the agent-file set (no transcript reads yet).
let newestMtime = 0;
let journalPath: string | null = null;
let journalMtime = 0;
const agentFiles = new Map<string, LiveAgentFile>();
for (const name of entries) {
if (name !== LIVE_JOURNAL_FILE && !name.startsWith(LIVE_AGENT_PREFIX)) continue;
const full = join(live.dirPath, name);
let m = 0;
try {
m = (await stat(full)).mtimeMs;
} catch {
continue; // entry vanished — ignore
}
if (m > newestMtime) newestMtime = m;
if (name === LIVE_JOURNAL_FILE) {
journalPath = full;
journalMtime = m;
continue;
}
// agent-<stem>.jsonl (transcript) or agent-<stem>.meta.json (queued-slot marker)
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
if (!stem) continue;
const slot = agentFiles.get(stem) || { agentId: stem };
if (name.endsWith(LIVE_TRANSCRIPT_SUFFIX)) {
slot.transcriptPath = full;
slot.transcriptMtime = m;
}
agentFiles.set(stem, slot);
}
if (agentFiles.size === 0) return; // nothing to show yet
// Skip the expensive parse when nothing changed since the last synthesis.
if (this.liveDirMtimes.get(live.dirPath) === newestMtime) return;
this.liveDirMtimes.set(live.dirPath, newestMtime);
const info = await this.parseLiveDir(live, agentFiles, journalPath, journalMtime, newestMtime);
if (!info) return;
if (this.liveDirMtimes.get(live.dirPath) === info.lastActivityAt) return;
this.liveDirMtimes.set(live.dirPath, info.lastActivityAt);
const existed = this.runs.has(info.runId);
this.runs.set(info.runId, info);
@@ -362,56 +542,87 @@ export class WorkflowRunWatcher extends EventEmitter {
}
/**
* Build a minimal ACTIVE WorkflowRunInfo from `subagents/workflows/wf_<id>/`.
* The transcript tree carries no phases/tokens — those arrive with the
* completion wf_*.json — so we expose: the agent slots (keyed by agentId, so the
* card→transcript click still works), each marked done/running from journal
* `result` lines, and lastActivityAt from the newest agent/journal mtime.
* Build an ACTIVE WorkflowRunInfo from a live transcript dir, ENRICHED so the
* floating window / panel show real data mid-run rather than empty slots:
* - per-agent tokens + tool-calls + model + last tool, parsed from each
* `agent-<id>.jsonl` (mtime-cached — only changed transcripts are re-read);
* - per-agent state: 'done' once the journal logs a `result` (→ green badge),
* 'progress' once it logs `started` or a transcript exists (→ yellow), else 'start';
* - run name/summary/phases from the live `workflows/scripts/<name>-<runId>.js`
* (the completion wf_<id>.json, which carries these, only lands at the end);
* - run totals = sums of the per-agent stats.
*/
private async parseLiveDir(live: DiscoveredLiveRun): Promise<WorkflowRunInfo | null> {
let entries: string[];
try {
entries = await readdir(live.dirPath);
} catch {
return null; // vanished between discover and read
}
private async parseLiveDir(
live: DiscoveredLiveRun,
agentFiles: Map<string, LiveAgentFile>,
journalPath: string | null,
journalMtime: number,
newestMtime: number
): Promise<WorkflowRunInfo | null> {
const { startedOrder, doneIds } = journalPath
? await this.readJournal(journalPath, journalMtime)
: { startedOrder: [] as string[], doneIds: new Set<string>() };
const startIndex = new Map<string, number>();
startedOrder.forEach((id, i) => startIndex.set(id, i));
const agentIds = new Set<string>();
let newestMtime = 0;
for (const name of entries) {
if (name.startsWith(LIVE_AGENT_PREFIX)) {
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
if (stem) agentIds.add(stem);
}
if (name === LIVE_JOURNAL_FILE || name.startsWith(LIVE_AGENT_PREFIX)) {
try {
const m = (await stat(join(live.dirPath, name))).mtimeMs;
if (m > newestMtime) newestMtime = m;
} catch {
// entry vanished — ignore
}
}
}
if (agentIds.size === 0) return null; // nothing to show yet
// Order agents by journal launch order; not-yet-started ones trail (sorted by id).
const ids = Array.from(agentFiles.keys()).sort((a, b) => {
const ia = startIndex.has(a) ? (startIndex.get(a) as number) : Number.MAX_SAFE_INTEGER;
const ib = startIndex.has(b) ? (startIndex.get(b) as number) : Number.MAX_SAFE_INTEGER;
if (ia !== ib) return ia - ib;
return a < b ? -1 : a > b ? 1 : 0;
});
const doneIds = await this.readJournalDoneAgents(join(live.dirPath, LIVE_JOURNAL_FILE));
const agents: WorkflowAgentInfo[] = Array.from(agentIds)
.sort()
.map((id, i) => ({
let totalTokens = 0;
let totalToolCalls = 0;
let defaultModel = '';
const agents: WorkflowAgentInfo[] = [];
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const file = agentFiles.get(id) as LiveAgentFile;
const stats = file.transcriptPath
? await this.agentTranscriptStats(file.transcriptPath, file.transcriptMtime || 0)
: null;
const state = doneIds.has(id) ? 'done' : startIndex.has(id) || file.transcriptPath ? 'progress' : 'start';
if (stats) {
totalTokens += stats.tokens;
totalToolCalls += stats.toolCalls;
if (stats.model && !defaultModel) defaultModel = stats.model;
}
agents.push({
index: i + 1,
label: `agent ${i + 1}`,
phaseIndex: 1,
phaseTitle: '',
model: '',
state: doneIds.has(id) ? 'done' : 'progress',
model: stats?.model || '',
state,
agentId: id,
}));
tokens: stats ? stats.tokens : undefined,
toolCalls: stats ? stats.toolCalls : undefined,
lastToolName: stats?.lastToolName,
promptPreview: stats?.promptPreview,
});
}
// Script meta is immutable for the life of a run, so derive it at most once.
// Re-derive only while we still lack a name (the script can land a poll or two
// after the first transcripts, which seed the run before the script exists).
let meta = this.liveMetaCache.get(live.runId);
if (!meta || !meta.workflowName) {
meta = await this.deriveLiveWorkflowMeta(live);
this.liveMetaCache.set(live.runId, meta);
}
return {
runId: live.runId,
workflowName: meta.workflowName,
summary: meta.summary,
status: 'running',
agentCount: agents.length,
phases: [],
totalTokens,
totalToolCalls,
defaultModel: defaultModel || undefined,
phases: meta.phases,
agents,
sessionUuid: live.sessionUuid,
projectHash: live.projectHash,
@@ -419,25 +630,145 @@ export class WorkflowRunWatcher extends EventEmitter {
};
}
/** Agent ids that already emitted a `result` event in the run journal. */
private async readJournalDoneAgents(journalPath: string): Promise<Set<string>> {
const done = new Set<string>();
/** Parse one agent transcript for token/tool stats; cached on the file's mtime. */
private async agentTranscriptStats(path: string, mtimeMs: number): Promise<AgentTranscriptStats | null> {
// peek (not get) on the hit-check so a cache HIT doesn't churn the LRU — every
// active agent is looked up each poll, and get() would delete+reinsert each time.
const cached = this.agentStatCache.peek(path);
if (cached && cached.mtimeMs === mtimeMs) return cached.stats;
let text: string;
try {
text = await readFile(path, 'utf-8');
} catch {
return null; // vanished mid-poll
}
let tokens = 0;
let toolCalls = 0;
let model = '';
let lastToolName: string | undefined;
let promptPreview: string | undefined;
for (const line of text.split('\n')) {
if (!line) continue;
let entry: { type?: string; message?: unknown };
try {
entry = JSON.parse(line) as { type?: string; message?: unknown };
} catch {
continue; // tolerate a partially-written trailing line
}
const msg = entry.message as
| {
model?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
content?: Array<{ type?: string; name?: string; text?: string }> | string;
}
| undefined;
if (!promptPreview && entry.type === 'user' && msg) {
promptPreview = truncate(extractMessageText(msg.content), PROMPT_PREVIEW_MAX);
}
if (!msg || typeof msg !== 'object') continue;
if (typeof msg.model === 'string' && msg.model) model = msg.model;
const u = msg.usage;
if (u) {
const total =
(u.input_tokens || 0) +
(u.output_tokens || 0) +
(u.cache_read_input_tokens || 0) +
(u.cache_creation_input_tokens || 0);
// Anthropic usage already reflects cumulative context, so the agent's running
// token total ≈ its LATEST usage-bearing message — last non-zero wins.
if (total > 0) tokens = total;
}
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block && block.type === 'tool_use' && typeof block.name === 'string') {
toolCalls++;
lastToolName = block.name;
}
}
}
}
const stats: AgentTranscriptStats = { tokens, toolCalls, model, lastToolName, promptPreview };
this.agentStatCache.set(path, { mtimeMs, stats });
return stats;
}
/**
* Read the run journal: agent ids in `started` order plus the set that already
* logged a terminal `result`. (`started` lines appear in launch order.)
*/
private async readJournal(
journalPath: string,
mtimeMs: number
): Promise<{ startedOrder: string[]; doneIds: Set<string> }> {
const cached = this.journalCache.get(journalPath);
if (cached && cached.mtimeMs === mtimeMs) return cached.parsed;
const startedOrder: string[] = [];
const seenStarted = new Set<string>();
const doneIds = new Set<string>();
let text: string;
try {
text = await readFile(journalPath, 'utf-8');
} catch {
return done; // journal not written yet — all agents still in progress
return { startedOrder, doneIds }; // journal not written yet
}
for (const line of text.split('\n')) {
if (!line) continue;
try {
const ev = JSON.parse(line) as { type?: string; agentId?: string };
if (ev && ev.type === 'result' && typeof ev.agentId === 'string') done.add(ev.agentId);
if (!ev || typeof ev.agentId !== 'string') continue;
if (ev.type === 'started' && !seenStarted.has(ev.agentId)) {
seenStarted.add(ev.agentId);
startedOrder.push(ev.agentId);
} else if (ev.type === 'result') {
doneIds.add(ev.agentId);
}
} catch {
// tolerate a partially-written trailing line
}
}
return done;
const parsed = { startedOrder, doneIds };
this.journalCache.set(journalPath, { mtimeMs, parsed });
return parsed;
}
/**
* Derive name/summary/phases for a live run from its persisted script file
* `…/workflows/scripts/<name>-<runId>.js`. The filename yields a reliable name;
* `meta.description`/`meta.phases` are best-effort regex extractions (the script is
* JS we must not eval), so any parse miss simply leaves those optional fields absent.
*/
private async deriveLiveWorkflowMeta(live: DiscoveredLiveRun): Promise<LiveWorkflowMeta> {
const empty: LiveWorkflowMeta = { phases: [] };
const scriptsDir = join(this.projectsDir, live.projectHash, live.sessionUuid, WORKFLOWS_SUBDIR, SCRIPTS_SUBDIR);
let names: string[];
try {
names = await readdir(scriptsDir);
} catch {
return empty;
}
const suffix = `-${live.runId}.js`;
const fileName = names.find((n) => n.endsWith(suffix)) || names.find((n) => n.includes(live.runId));
if (!fileName) return empty;
const nameFromFile = fileName.endsWith(suffix) ? fileName.slice(0, -suffix.length) : fileName.replace(/\.js$/, '');
let body = '';
try {
body = (await readFile(join(scriptsDir, fileName), 'utf-8')).slice(0, SCRIPT_READ_MAX_BYTES);
} catch {
return { workflowName: nameFromFile || undefined, phases: [] };
}
// Scope name/summary/phases parsing to the `meta` literal so later script
// declarations (schemas, prose) can't be mistaken for it.
const metaBlock = extractMetaBlock(body) || body;
return {
workflowName: nameFromFile || extractMetaString(metaBlock, 'name') || undefined,
summary: extractMetaString(metaBlock, 'description') || undefined,
phases: extractMetaPhases(metaBlock),
};
}
/**
+14 -2
View File
@@ -369,7 +369,7 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: undefined });
});
it('passes viewport type through for resize arbitration', async () => {
@@ -381,7 +381,19 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: undefined });
});
it('passes force resize through for redraw requests', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 120, rows: 40, force: true },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
});
it('rejects cols exceeding max (500)', async () => {
+19 -4
View File
@@ -285,7 +285,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(120, 40);
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: false });
});
} finally {
ws.close();
@@ -300,7 +300,22 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: false });
});
} finally {
ws.close();
}
});
it('passes force resize through for redraw requests', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40, f: true }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
});
} finally {
ws.close();
@@ -342,7 +357,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(1, 1);
expect(session.resize).toHaveBeenCalledWith(1, 1, { viewportType: undefined, force: false });
});
} finally {
ws.close();
@@ -357,7 +372,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(500, 200);
expect(session.resize).toHaveBeenCalledWith(500, 200, { viewportType: undefined, force: false });
});
} finally {
ws.close();
+9
View File
@@ -113,6 +113,15 @@ describe('Session resize arbitration', () => {
expect(resize).toHaveBeenCalledWith(100, 30);
});
it('applies forced resizes even when the dimensions did not change', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 120, 40);
session.resize(120, 40, { force: true });
expect(resize).toHaveBeenCalledWith(120, 40);
});
describe('idle-desktop override (whoever is active wins)', () => {
afterEach(() => {
vi.useRealTimers();
+116
View File
@@ -0,0 +1,116 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadTerminalUiHarness() {
const CodemanApp = function CodemanApp(this: any) {};
let now = 1_000;
const context = vm.createContext({
window: {},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => now },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
URL: {
createObjectURL: () => 'blob:yield',
revokeObjectURL: () => {},
},
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: {
isTouchDevice: () => true,
},
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as any)();
return {
app,
setNow: (value: number) => {
now = value;
},
};
}
function createElementHarness() {
const listeners = new Map<string, (ev: any) => void>();
return {
element: {
addEventListener: vi.fn((type: string, listener: (ev: any) => void) => {
listeners.set(type, listener);
}),
},
dispatch(type: string, event: any) {
listeners.get(type)?.(event);
},
};
}
describe('terminal touch tap mouse guard', () => {
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
const { app } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
const event = {
isTrusted: true,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(event.stopImmediatePropagation).toHaveBeenCalledOnce();
});
it('allows the app synthetic mouse event through the tap window', () => {
const { app } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
const event = {
isTrusted: false,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
});
it('allows trusted mouse events after the tap window expires', () => {
const { app, setNow } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
setNow(2_000);
const event = {
isTrusted: true,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
});
});
+96
View File
@@ -317,3 +317,99 @@ describe('WorkflowRunWatcher — in-flight (live) runs', () => {
expect(watcher.getAllRuns()).toHaveLength(1);
});
});
/**
* Live ENRICHMENT: while a run is in-flight the watcher now parses each agent
* transcript for real tokens/tool-calls/model, maps state→colour from the journal,
* and derives the run name/summary/phases from the persisted script — so the
* floating window/panel show real data mid-run instead of "0 tok / agent N".
*/
describe('WorkflowRunWatcher — live enrichment (tokens/state/name)', () => {
const RUN = 'wf_enrich01-abc';
let projectsDir: string;
let watcher: WorkflowRunWatcher;
beforeEach(async () => {
projectsDir = await mkdtemp(join(tmpdir(), 'wfw-enrich-'));
const sessionDir = join(projectsDir, PROJECT_HASH, SESSION_UUID);
const liveDir = join(sessionDir, 'subagents', 'workflows', RUN);
await mkdir(liveDir, { recursive: true });
const scriptsDir = join(sessionDir, 'workflows', 'scripts');
await mkdir(scriptsDir, { recursive: true });
// Agent aaa: a user prompt + two assistant turns with usage + two tool_use blocks.
await writeFile(
join(liveDir, 'agent-aaa.jsonl'),
[
'{"type":"user","message":{"role":"user","content":"Audit the docs"}}',
'{"type":"assistant","message":{"model":"claude-opus-4-8","usage":{"input_tokens":1000,"output_tokens":40},"content":[{"type":"tool_use","name":"Bash"}]}}',
'{"type":"assistant","message":{"model":"claude-opus-4-8","usage":{"input_tokens":2000,"cache_read_input_tokens":500,"output_tokens":80},"content":[{"type":"tool_use","name":"Edit"},{"type":"text","text":"done"}]}}',
].join('\n') + '\n',
'utf-8'
);
await writeFile(join(liveDir, 'agent-aaa.meta.json'), JSON.stringify({ agentType: 'workflow-subagent' }), 'utf-8');
// Agent bbb: started, no result yet, minimal transcript (no usage).
await writeFile(join(liveDir, 'agent-bbb.jsonl'), '{"type":"assistant","message":{"content":[]}}\n', 'utf-8');
// bbb starts BEFORE aaa, so journal launch order ['bbb','aaa'] differs from the
// old alphabetical sort ['aaa','bbb'] — the ordering assertion below is adversarial.
await writeFile(
join(liveDir, 'journal.jsonl'),
'{"type":"started","agentId":"bbb"}\n{"type":"started","agentId":"aaa"}\n{"type":"result","agentId":"aaa","result":{}}\n',
'utf-8'
);
// Persisted script — name from filename, summary/phases from the meta literal.
await writeFile(
join(scriptsDir, `my-cool-workflow-${RUN}.js`),
"export const meta = {\n name: 'my-cool-workflow',\n description: 'Audit and update the docs',\n phases: [ { title: 'Plan', detail: 'plan it' }, { title: 'Do', detail: 'do it' } ],\n}\n",
'utf-8'
);
watcher = new WorkflowRunWatcher(projectsDir);
});
afterEach(async () => {
watcher.stop();
await rm(projectsDir, { recursive: true, force: true });
});
function firstRun(): Promise<WorkflowRunInfo> {
return new Promise<WorkflowRunInfo>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('timed out')), 5000);
watcher.once('run_discovered', (info: WorkflowRunInfo) => {
clearTimeout(t);
resolve(info);
});
watcher.start();
});
}
it('derives workflowName/summary/phases from the live script file', async () => {
const info = await firstRun();
expect(info.workflowName).toBe('my-cool-workflow');
expect(info.summary).toBe('Audit and update the docs');
expect(info.phases.map((p) => p.title)).toEqual(['Plan', 'Do']);
});
it('parses per-agent tokens (last usage-bearing message) + tool-call counts from the transcript', async () => {
const info = await firstRun();
const aaa = info.agents.find((a) => a.agentId === 'aaa')!;
expect(aaa.tokens).toBe(2580); // last turn: 2000 in + 500 cache + 80 out
expect(aaa.toolCalls).toBe(2); // Bash + Edit
expect(aaa.model).toBe('claude-opus-4-8');
expect(aaa.promptPreview).toBe('Audit the docs');
});
it('maps state to done (→green) / progress (→yellow) from the journal', async () => {
const info = await firstRun();
expect(info.agents.find((a) => a.agentId === 'aaa')!.state).toBe('done');
expect(info.agents.find((a) => a.agentId === 'bbb')!.state).toBe('progress');
});
it('orders agents by journal launch order (NOT alphabetical) and sums run-level totals', async () => {
const info = await firstRun();
// bbb started first in the journal though it sorts after aaa — launch order wins.
expect(info.agents.map((a) => a.agentId)).toEqual(['bbb', 'aaa']);
expect(info.agents[0].label).toBe('agent 1'); // = bbb, the first-launched
expect(info.totalToolCalls).toBe(2);
expect(info.totalTokens).toBe(2580);
});
});