mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3183abf7 | ||
|
|
15a43894f9 | ||
|
|
e20aa1d4d8 | ||
|
|
aa28ef048c | ||
|
|
67f6ed3168 | ||
|
|
2d4616f059 | ||
|
|
35f8f9d19f | ||
|
|
c992784681 | ||
|
|
3a7be356ae | ||
|
|
a6a572e635 | ||
|
|
26416f98de | ||
|
|
084d7b7328 | ||
|
|
a4cdb352be | ||
|
|
d81454b6f9 | ||
|
|
00f1b9228a | ||
|
|
fa4c36c2a5 | ||
|
|
3b85001fed | ||
|
|
623fedf5b7 | ||
|
|
1410362e5b | ||
|
|
92ae46246c | ||
|
|
6831d79127 | ||
|
|
b01ed611c4 | ||
|
|
3a106bd048 |
@@ -1,5 +1,75 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.16.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Phone home screen now shows session ages, plus three mobile input fixes.
|
||||
|
||||
**Phone overview: started / how long stamps.** Every live session row on the "C" home screen carries a third line: when the session first started, and how long it has been in the state it is in ("started 3d ago · idle 12m"). Idle, waiting, error and ended states measure from the pane's last output, which for a Claude pane sitting at its composer is exactly when the turn ended; a WORKING session measures from its last Enter instead, because a running pane repaints about once a second and would otherwise report every turn as 0m. A 20s clock rewrites the values in place rather than re-rendering, so no row's blink or pulse restarts.
|
||||
|
||||
**Fix: a recovered session was restamped as new on every restart.** Boot recovery never passed `createdAt`, so each server start reset it to `Date.now()` and a week-old pane reported "created 2m ago" (and sorted as the newest thing in the unified session list). It now comes from the tmux session's own birth time, which mux-sessions.json already carried. The desktop home rail's "created" stamp is fixed by the same change.
|
||||
|
||||
**Fix: a selection dialog locked the on-screen keyboard out of the terminal (regression in 1.16.5).** The check that decides whether a tap belongs to the TUI scanned the whole viewport for a numbered menu, so while a Claude question or permission dialog was on screen EVERY tap in the terminal counted as actionable and blurred the input. The keyboard could not be opened at all until the dialog was answered, which left tapping an option, the one gesture that commits an answer, as the only interaction a phone had. The menu test is now row-local: the dialog's own rows still report the tap and keep the keyboard down, while the question title, the transcript and blank space summon the keyboard so a digit can be typed at the dialog instead of aimed at it.
|
||||
|
||||
**Fix: the accessory bar's arrow keys bypassed the local-echo overlay.** On a phone the text you type is buffered in the browser and has never reached the PTY, so an arrow tapped on the bar arrived at a composer the CLI still considered empty: Up recalled a history entry into it while the overlay went on painting the draft over the same row and still believed it was pending, and the next Enter submitted the two mixed together. The four arrows now flush the draft first and hand the session to plain PTY echo, the same contract a nav key typed on a hardware keyboard has had since #218. The CLI stashes the flushed draft, so Down brings it back. Tab now shares that one flush helper instead of its own copy.
|
||||
|
||||
## 1.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile keyboard dismissal, and a tidier Save/Close pair in the phone settings sheet.
|
||||
|
||||
**The on-screen keyboard can finally be closed from inside the app.** The terminal
|
||||
keeps focus on a hidden textarea and nothing ever released it, so once the keyboard
|
||||
was up it covered roughly half the screen with no way out but the OS back gesture.
|
||||
Two gestures now dismiss it:
|
||||
- **A tap outside the terminal** (header, tab strip, empty page chrome). Deliberately
|
||||
narrow: it only fires while the terminal input actually holds focus, never inside
|
||||
the terminal (tap classification owns that decision), and never on a control, since
|
||||
anything focusable is about to take focus itself and the keyboard accessory bar
|
||||
exists to be used _while_ the keyboard is open. A scroll ends in `touchend` too, so
|
||||
finger travel is tracked from `touchstart` and only a near-stationary gesture counts
|
||||
as a tap, sharing the terminal's own 8px threshold so both agree on tap-vs-scroll.
|
||||
Scrolling to read something mid-compose no longer drops the composer.
|
||||
- **A second tap on inert transcript content.** Every terminal tap used to re-focus,
|
||||
which left the accessory bar's chevron as the only way out. Scoped to inert rows on
|
||||
purpose: the prompt row keeps focus-then-position, so a second tap there still
|
||||
places the caret, and actionable rows (readbacks, `esc to interrupt` status rows,
|
||||
menu selections) still blur as before.
|
||||
|
||||
**Settings sheet header on phones.** Below 860px Save moves into the header, which
|
||||
left the two ways out of the sheet as a fat accent pill beside a bare glyph. Save and
|
||||
Close now share a recessed tray with matching 36px pill geometry, reading as one
|
||||
44px cluster the height of the phone header. Tray colors come from skin tokens, so
|
||||
the light skins keep their look, and the tray stays off the sheets that carry a lone
|
||||
close button.
|
||||
|
||||
Also fixes a test that could never have caught a regression: the case asserting that
|
||||
tapping a control does _not_ dismiss the keyboard was picking a button from the
|
||||
hidden welcome overlay, whose rect still measures while the hit-test lands on the
|
||||
terminal underneath, so it passed for the wrong reason and stayed green even with the
|
||||
exemption deleted. All four guards in the dismiss handler are now individually
|
||||
pinned.
|
||||
|
||||
## 1.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Voice dictation through your Claude Code login (no API key).** The mic button can now transcribe using this machine's existing Claude Code subscription, via the same speech-to-text service the CLI's own `/voice` mode uses. Off by default (`claudeVoiceEnabled`, synced): turning it on spends the server owner's Claude subscription on transcription for anyone who can reach the UI. The OAuth token never leaves the server process, credentials are read-only (Codeman never refreshes them, which would rotate the refresh token out from under the CLI), streams are capped at 5 minutes and 4 concurrent, and the WebSocket carries the same allowed-Host + same-site Origin guard as the terminal socket. A new Speech engine picker (Auto / Claude / Deepgram / Browser) sits alongside the existing Deepgram and Web Speech paths, which are untouched.
|
||||
|
||||
**One settings surface.** Session Options and Add Case now use the same `set-*` chrome as App Settings instead of the old modal-tab chrome, with a left rail, grouped rows, per-group device/synced scope badges and a search box. App Settings leads with version + update; the Session Options rail stays a real switcher (one section at a time) because Summary and Respawn are each long enough to bury the other. Collapsed Add Case blocks gained a disclosure chevron.
|
||||
|
||||
**Read My Mind: rethink steer note (phase 3 part 2).** Rethink now carries an optional free-text note ("no, I meant the mobile bug") sent as `steer`, the highest-authority signal the predictor gets. It stays in the field across re-runs, clears on each open, and the empty-result copy points at it. The modal footer moved to the styled `btn-toolbar` convention; the bare `btn btn-*` classes it shipped with match no CSS in this codebase and rendered as unstyled browser buttons.
|
||||
|
||||
**Mobile terminal taps no longer fight the keyboard.** Taps on TUI-owned rows (expandable readbacks, tool results, decision menus, the working/status row) now act on the CLI without popping the keyboard, while a tap on inert transcript text keeps the keyboard reachable. Rows are told apart by the affordance the CLI prints (`ctrl+r to expand`, `tap to collapse`, `esc to interrupt`) rather than by row titles, which vary per CLI and per version. A tap with the viewport scrolled up sends no mouse report at all but still restores focus, so the keyboard is reachable after every tab switch. Thanks to @Lint111.
|
||||
|
||||
**Path labels abbreviate `$HOME` on both platforms.** The "show `~/project`" rule had three implementations and two were platform-specific in opposite directions: the Run menu's matched `/home/<user>/` only, so on macOS every Recent Sessions row spent its first ~19 characters on an identical `/Users/<user>/` prefix and ellipsized away the tail that identifies it (#273); the case-manage list's matched `/Users/<user>` only, so no Linux case path was ever abbreviated. Both now route through one helper, with a static guard against a fourth copy appearing.
|
||||
|
||||
**Run menu Recent Sessions rows are legible.** Rows now read as folder, worktree pill, dimmed parent path, timestamp, with only the parent path allowed to shrink, so truncation can never hide which project (or which worktree) a row refers to. `<repo>/.claude/worktrees` is dropped from the parent path as noise. Thanks to @jordan8037310. Follow-up fix: the widened menu was not actually usable by its rows, since `.run-mode-history` is a block scroller and its `<button>` rows stayed shrink-to-fit at ~250px inside a full-window-width menu; rows now fill the menu and it is capped at the 760px one full row costs.
|
||||
|
||||
**Desktop home screen** no longer clips, and shows full tab names.
|
||||
|
||||
## 1.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.16.3 (must match `package.json`)
|
||||
**Version**: 1.16.6 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -280,6 +280,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
|
||||
|
||||
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions. **(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. **(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`. ⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. ⚠️ **`test:ci` excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm test -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 430px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.3",
|
||||
"version": "1.16.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.3",
|
||||
"version": "1.16.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.3",
|
||||
"version": "1.16.6",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+6
-1
@@ -591,7 +591,12 @@ export class Session extends EventEmitter {
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._resumeSessionId = config.resumeSessionId;
|
||||
this._lastActivityAt = this.createdAt;
|
||||
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
||||
// days-old tmux session, and seeding last-activity from it would report a
|
||||
// freshly re-attached pane as having been silent for days, which the idle
|
||||
// confirmation reads as "already quiet" and the home screens print as its
|
||||
// idle duration. For a genuinely new session the two are the same instant.
|
||||
this._lastActivityAt = Date.now();
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = config.resumeSessionId || this.id;
|
||||
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
||||
|
||||
@@ -729,16 +729,16 @@ const KeyboardAccessoryBar = {
|
||||
this.toggleCtrl();
|
||||
break;
|
||||
case 'scroll-up':
|
||||
this.sendKey('\x1b[A');
|
||||
this.sendNavKey('\x1b[A');
|
||||
break;
|
||||
case 'scroll-down':
|
||||
this.sendKey('\x1b[B');
|
||||
this.sendNavKey('\x1b[B');
|
||||
break;
|
||||
case 'arrow-left':
|
||||
this.sendKey('\x1b[D');
|
||||
this.sendNavKey('\x1b[D');
|
||||
break;
|
||||
case 'arrow-right':
|
||||
this.sendKey('\x1b[C');
|
||||
this.sendNavKey('\x1b[C');
|
||||
break;
|
||||
case 'esc':
|
||||
this.sendKey('\x1b');
|
||||
@@ -746,26 +746,12 @@ const KeyboardAccessoryBar = {
|
||||
case 'opt-enter':
|
||||
this.sendKey('\x1b\r');
|
||||
break;
|
||||
case 'tab': {
|
||||
case 'tab':
|
||||
// Tab means "complete what I just typed", but with local echo the typed
|
||||
// text is still buffered in the overlay and has never reached the PTY —
|
||||
// a bare \t would ask the CLI to complete an empty composer. Flush the
|
||||
// pending text first (same steps as the Shift+Enter branch in
|
||||
// terminal-ui.js), then send \t after the sendCommand settle delay.
|
||||
const overlay = app._localEchoOverlay;
|
||||
const pending = (app._localEchoEnabled && overlay?.pendingText) || '';
|
||||
if (pending) {
|
||||
overlay.clear();
|
||||
overlay.suppressBufferDetection?.();
|
||||
app._flushedOffsets?.delete(app.activeSessionId);
|
||||
app._flushedTexts?.delete(app.activeSessionId);
|
||||
app.sendInput(pending);
|
||||
setTimeout(() => this.sendKey('\t'), 120);
|
||||
} else {
|
||||
this.sendKey('\t');
|
||||
}
|
||||
// a bare \t would ask the CLI to complete an empty composer.
|
||||
this.flushPendingThen(() => this.sendKey('\t'));
|
||||
break;
|
||||
}
|
||||
case 'shift-tab':
|
||||
this.sendKey('\x1b[Z');
|
||||
break;
|
||||
@@ -860,6 +846,51 @@ const KeyboardAccessoryBar = {
|
||||
setTimeout(() => app.sendInput('\r'), 120);
|
||||
},
|
||||
|
||||
/**
|
||||
* Flush whatever the local-echo overlay is still holding, THEN run `after()`.
|
||||
*
|
||||
* On a phone the characters you type sit in the overlay and have never
|
||||
* reached the PTY, so any key that acts on "what I just typed" has to push
|
||||
* that text out first or the CLI acts on an empty composer. Mirrors the
|
||||
* flush the typed path performs in terminal-ui.js's onData; the 120ms is the
|
||||
* same settle delay sendCommand uses, so the text lands before the key.
|
||||
*/
|
||||
flushPendingThen(after) {
|
||||
const overlay = app._localEchoOverlay;
|
||||
const pending = (app._localEchoEnabled && overlay?.pendingText) || '';
|
||||
if (!pending) {
|
||||
after();
|
||||
return;
|
||||
}
|
||||
overlay.clear();
|
||||
overlay.suppressBufferDetection?.();
|
||||
app._flushedOffsets?.delete(app.activeSessionId);
|
||||
app._flushedTexts?.delete(app.activeSessionId);
|
||||
app.sendInput(pending);
|
||||
setTimeout(after, 120);
|
||||
},
|
||||
|
||||
/**
|
||||
* A composer nav key (the four arrows) from the bar, under the SAME contract
|
||||
* as pressing one on a hardware keyboard (the `isComposerNavKey` branch of
|
||||
* terminal-ui.js's onData): flush the unsent draft so the key edits the real
|
||||
* composer, then hand the session to plain PTY echo until Enter or Ctrl+C,
|
||||
* because after a nav key the cursor can sit mid-text where the overlay's
|
||||
* append-only buffering cannot track edits (issue #218).
|
||||
*
|
||||
* Without the flush the arrow reached a composer the CLI still saw as EMPTY:
|
||||
* Up recalled a history entry into it while the overlay went on painting the
|
||||
* draft over the same row and still believed it was pending. The draft was
|
||||
* then submitted on top of the recalled text, and history recall looked
|
||||
* broken because what came back was never what the row showed.
|
||||
*/
|
||||
sendNavKey(sequence) {
|
||||
if (!app.activeSessionId) return;
|
||||
if (!app._echoPassthroughSessions) app._echoPassthroughSessions = new Set();
|
||||
app._echoPassthroughSessions.add(app.activeSessionId);
|
||||
this.flushPendingThen(() => this.sendKey(sequence));
|
||||
},
|
||||
|
||||
/** Send a special key (arrow, escape, etc.) directly to the PTY.
|
||||
* Bypasses tmux send-keys -l (literal mode) since escape sequences
|
||||
* must be written raw to be interpreted as key presses by Ink. */
|
||||
|
||||
@@ -14,12 +14,19 @@
|
||||
* only this module removes it, so desktop (which never loads mobile.css) cannot
|
||||
* render an unstyled overview even if a class rule leaked.
|
||||
*
|
||||
* Each live row also carries when the session FIRST started and how long it has
|
||||
* been in the state it is in ("started 3d ago · idle 12m"). Both go stale on
|
||||
* their own (a sitting session emits no event), so a slow clock rewrites them
|
||||
* IN PLACE from the epoch ms parked on the elements, never by re-rendering,
|
||||
* which would restart every row's blink and pulse.
|
||||
*
|
||||
* Everything renders from state the page already holds (`this.sessions`,
|
||||
* `this.cases`, `this.pendingHooks`) — no endpoint, no SSE event, no schema.
|
||||
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
||||
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
|
||||
* @dependency mobile-handlers.js (MobileDetection)
|
||||
* @dependency session-ui.js (selectQuickStartCase for "New session here")
|
||||
* @loadorder 12.55 of 16, after webview-tabs.js, before entrance-animations.js
|
||||
@@ -64,6 +71,23 @@ const MOBILE_OVERVIEW_PILL_LABEL = {
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
/**
|
||||
* Label for the "how long has it been like this" stamp, per state. The pill
|
||||
* already names the state, so this word is there to say what the duration next
|
||||
* to it is measuring.
|
||||
*/
|
||||
const MOBILE_OVERVIEW_SINCE_LABEL = {
|
||||
needs: 'waiting',
|
||||
waiting: 'waiting',
|
||||
error: 'failed',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
done: 'ended',
|
||||
};
|
||||
|
||||
/** How often the age stamps are rewritten in place while the home screen is up. */
|
||||
const MOBILE_OVERVIEW_CLOCK_MS = 20000;
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Model (pure)
|
||||
@@ -87,6 +111,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
return 'idle';
|
||||
},
|
||||
|
||||
/**
|
||||
* Anchor + label for the row's second stamp: how long the session has been in
|
||||
* the state it is in.
|
||||
*
|
||||
* For everything that is NOT working that anchor is `lastActivityAt`, the last
|
||||
* byte the pane printed: a Claude pane sitting at its composer prints nothing,
|
||||
* so the end of the last turn is exactly when the session went quiet.
|
||||
*
|
||||
* A WORKING pane is the opposite: it repaints about once a second, so its
|
||||
* last-activity stamp is always "now" and would report every running turn as
|
||||
* 0m. The turn's own start is the pane's last Enter (`lastSubmitAt`), which is
|
||||
* persisted server-side and therefore survives a Codeman restart. A session
|
||||
* that has never submitted has no anchor at all, and gets no stamp rather than
|
||||
* a made-up one.
|
||||
*
|
||||
* @returns {{key: string, at: number}|null}
|
||||
*/
|
||||
_mobileOverviewSince(state, session) {
|
||||
const at = state === 'working' ? Number(session.lastSubmitAt) || 0 : Number(session.lastActivityAt) || 0;
|
||||
if (!at) return null;
|
||||
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
||||
},
|
||||
|
||||
/**
|
||||
* Longest-prefix match of a workingDir against the case list, so a session
|
||||
* started in a subdirectory still belongs to its case. Mirrors the matching in
|
||||
@@ -137,6 +184,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||
// Epoch ms, straight off the session payload; formatting happens at
|
||||
// render time so the clock can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||
};
|
||||
});
|
||||
@@ -224,6 +275,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
if (!el) return;
|
||||
this._closeMobileOverviewRunMenu();
|
||||
this._stopMobileOverviewClock();
|
||||
el.classList.remove('visible');
|
||||
el.hidden = true;
|
||||
},
|
||||
@@ -380,6 +432,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._mobileOverviewHistory ? 'No past conversations yet' : 'Loading…'
|
||||
)
|
||||
);
|
||||
|
||||
this._startMobileOverviewClock();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -623,6 +677,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
line2.textContent = row.mode + (row.dir ? ' · ' + row.dir : '');
|
||||
body.appendChild(line2);
|
||||
|
||||
body.appendChild(this._buildMobileOverviewMeta(row));
|
||||
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
@@ -654,6 +710,110 @@ Object.assign(CodemanApp.prototype, {
|
||||
return item;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Age stamps: started / how long in this state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The "started 3d ago · idle 12m" line under a session row. Both stamps keep
|
||||
* their raw epoch ms on the element (`data-mo-ts`) so `_tickMobileOverviewTimes()`
|
||||
* can rewrite the text without rebuilding the row (a re-render would restart
|
||||
* the blink on every waiting row and the pulse on every working one).
|
||||
*/
|
||||
_buildMobileOverviewMeta(row) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'mobile-overview-row-meta';
|
||||
// Relative times are generated text, and "started"/"idle" here are the same
|
||||
// generic words that mean something else on other surfaces.
|
||||
meta.setAttribute('data-i18n-skip', '');
|
||||
|
||||
meta.appendChild(this._buildMobileOverviewStamp('started', row.createdAt, 'ago', 'mobile-overview-meta-started'));
|
||||
|
||||
if (row.since) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'mobile-overview-meta-sep';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
sep.textContent = '·';
|
||||
meta.appendChild(sep);
|
||||
meta.appendChild(
|
||||
this._buildMobileOverviewStamp(row.since.key, row.since.at, 'for', 'mobile-overview-meta-since')
|
||||
);
|
||||
}
|
||||
|
||||
return meta;
|
||||
},
|
||||
|
||||
/** One labelled stamp: a dim key, the value, the full date in the title. */
|
||||
_buildMobileOverviewStamp(key, timestamp, format, className) {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'mobile-overview-meta-item ' + className;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'mobile-overview-meta-key';
|
||||
label.textContent = key;
|
||||
wrap.appendChild(label);
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.dataset.moTs = String(timestamp || 0);
|
||||
value.dataset.moFmt = format;
|
||||
value.textContent = this._mobileOverviewStampText(timestamp, format);
|
||||
wrap.appendChild(value);
|
||||
|
||||
if (timestamp) wrap.title = `${key}: ${new Date(timestamp).toLocaleString()}`;
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/**
|
||||
* 'ago' points at a moment ("3d ago", the app's one relative formatter);
|
||||
* 'for' measures a span from it to now ("12m"), which is what a duration
|
||||
* beside a state word wants to read as.
|
||||
*/
|
||||
_mobileOverviewStampText(timestamp, format) {
|
||||
if (!timestamp) return '—';
|
||||
if (format === 'ago') {
|
||||
return (this.formatRelativeTime && this.formatRelativeTime(timestamp)) || '—';
|
||||
}
|
||||
const ms = Date.now() - timestamp;
|
||||
if (ms < 60000) return '<1m';
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return mins % 60 ? `${hours}h ${mins % 60}m` : `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return hours % 24 ? `${days}d ${hours % 24}h` : `${days}d`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rewrites the stamps in place every `MOBILE_OVERVIEW_CLOCK_MS`. A sitting
|
||||
* session emits nothing, so without this its "idle 2m" would still read 2m an
|
||||
* hour later, the one number on the screen that has to move on its own.
|
||||
*/
|
||||
_startMobileOverviewClock() {
|
||||
if (this._mobileOverviewClock) return;
|
||||
this._mobileOverviewClock = setInterval(() => {
|
||||
if (!this.isMobileOverviewVisible()) {
|
||||
this._stopMobileOverviewClock();
|
||||
return;
|
||||
}
|
||||
this._tickMobileOverviewTimes();
|
||||
}, MOBILE_OVERVIEW_CLOCK_MS);
|
||||
},
|
||||
|
||||
_stopMobileOverviewClock() {
|
||||
if (!this._mobileOverviewClock) return;
|
||||
clearInterval(this._mobileOverviewClock);
|
||||
this._mobileOverviewClock = null;
|
||||
},
|
||||
|
||||
_tickMobileOverviewTimes() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
if (!el) return;
|
||||
for (const node of el.querySelectorAll('[data-mo-ts]')) {
|
||||
const text = this._mobileOverviewStampText(Number(node.dataset.moTs) || 0, node.dataset.moFmt);
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
},
|
||||
|
||||
/** The session's pending approval, when the strip should render (dialogs only). */
|
||||
_pendingApprovalForSession(sessionId) {
|
||||
if (!this.approvals || !this.approvalsInboxEnabled || !this.approvalsInboxEnabled()) return null;
|
||||
|
||||
@@ -2720,6 +2720,46 @@ html.mobile-init .file-browser-panel {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Third line of the row body: "STARTED 3d ago · IDLE 12m". Monospace so the
|
||||
numbers stay put as the clock rewrites them every 20s, and dimmer than the
|
||||
path above it: it answers a question you only ask on purpose. */
|
||||
.mobile-overview-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.63rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-overview-meta-item {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-overview-meta-key {
|
||||
margin-right: 0.3rem;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mobile-overview-meta-sep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* The freshest signal on the row: while a session is actually running, how
|
||||
long the current turn has been going is the number the eye should land on.
|
||||
Same treatment the desktop rail gives its "active" stamp. */
|
||||
.mobile-overview-row--working .mobile-overview-meta-since {
|
||||
color: var(--green);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.mobile-overview-dot {
|
||||
flex-shrink: 0;
|
||||
width: 9px;
|
||||
@@ -3157,9 +3197,36 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Save moves into the header; the bottom action bar would cost 60px */
|
||||
/* Save + Close are the two ways out of the sheet (save-and-close vs
|
||||
discard-and-close), hit in the same corner with the same thumb, so here —
|
||||
and only here, since Save is header-only below 860px — they share a
|
||||
recessed tray and matching pill geometry instead of reading as a fat
|
||||
accent pill parked beside a stray × glyph. Tray colors come from skin
|
||||
tokens, never a hardcoded black alpha, or the light skins get a grey slab.
|
||||
`:has()` keeps the tray off the two sheets that carry a lone × (Session
|
||||
Options and Add Case save from inside their own forms). */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions:has(.set-head-save) {
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
/* Save moves into the header; the bottom action bar would cost 60px. Both
|
||||
buttons grow to a thumb-sized target and keep identical heights so the
|
||||
pair reads as one cluster — 36 + the tray's 3px padding and 1px border on
|
||||
each side is a 44px block, the same height as the phone header. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save {
|
||||
display: inline-flex;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-foot {
|
||||
|
||||
@@ -490,26 +490,55 @@ Object.assign(CodemanApp.prototype, {
|
||||
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
// Shared helper, not a local regex: the copy that used to live here
|
||||
// matched `/home/<user>/` only, so on macOS every row rendered the same
|
||||
// unabbreviated `/Users/<user>/…` prefix and ellipsized away the tail
|
||||
// that identifies it (#273).
|
||||
// matched `/home/<user>/` only, so on macOS (`/Users/<user>/`) nothing was
|
||||
// stripped and every row spent its first ~19 characters on an identical
|
||||
// prefix — with the tail ellipsized, all rows rendered as
|
||||
// `/Users/jordanryan/co…` and became indistinguishable (#273).
|
||||
const shortDir = this._shortenHomePath(s.workingDir);
|
||||
// Lead with the folder that identifies the row; the parent path trails and
|
||||
// is what gets truncated. Truncation must never eat the identity.
|
||||
const lastSlash = shortDir.lastIndexOf('/');
|
||||
const leafName = lastSlash === -1 ? shortDir : shortDir.slice(lastSlash + 1);
|
||||
// `<repo>/.claude/worktrees` in the parent path is pure noise once the pill
|
||||
// says which worktree it is — drop it so the repo stays visible instead.
|
||||
const parentDir = (lastSlash === -1 ? '' : shortDir.slice(0, lastSlash)).replace(/\/\.claude\/worktrees$/, '');
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'run-mode-option';
|
||||
btn.className = 'run-mode-option run-mode-hist-row';
|
||||
btn.title = s.workingDir;
|
||||
btn.dataset.sessionId = s.sessionId;
|
||||
btn.dataset.workingDir = s.workingDir;
|
||||
|
||||
const dirSpan = document.createElement('span');
|
||||
dirSpan.className = 'hist-dir';
|
||||
dirSpan.textContent = shortDir;
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'hist-name';
|
||||
nameSpan.textContent = leafName;
|
||||
|
||||
const parts = [nameSpan];
|
||||
|
||||
// Worktree pill, same data the session rows use (#266). A worktree's
|
||||
// directory basename is often just the worktree name, so without this two
|
||||
// worktrees of one repo still read alike.
|
||||
const wt = this._worktreeLabel ? this._worktreeLabel(s) : '';
|
||||
if (wt) {
|
||||
const wtSpan = document.createElement('span');
|
||||
wtSpan.className = 'hist-wt';
|
||||
wtSpan.textContent = wt;
|
||||
parts.push(wtSpan);
|
||||
}
|
||||
|
||||
if (parentDir) {
|
||||
const dirSpan = document.createElement('span');
|
||||
dirSpan.className = 'hist-dir';
|
||||
dirSpan.textContent = parentDir;
|
||||
parts.push(dirSpan);
|
||||
}
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'hist-meta';
|
||||
metaSpan.textContent = timeStr;
|
||||
parts.push(metaSpan);
|
||||
|
||||
btn.append(dirSpan, metaSpan);
|
||||
btn.append(...parts);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
||||
|
||||
+113
-3
@@ -4455,6 +4455,28 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
max-width: 250px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Above phone width the menu becomes a full-width drawer across the bottom of the
|
||||
window. The 250px cap above was sized for a `~/<dir>/<repo>` row, which assumed
|
||||
the home prefix had been abbreviated — it never was on macOS, so real rows blew
|
||||
straight through it and ellipsized into `/Users/<user>/co…`, identical on every
|
||||
line. Width is what buys room for the worktree + branch, so the cap is lifted
|
||||
rather than nudged. Phones keep the compact popover: mobile.css positions this
|
||||
menu itself and a viewport-wide drawer there would cover the composer. */
|
||||
@media (min-width: 769px) {
|
||||
.run-mode-menu {
|
||||
/* Bounded, not viewport-wide. `100vw - 24px` spanned the whole window while
|
||||
every row stayed shrink-to-fit at ~250px (see below), so the menu grew by
|
||||
~1100px of dead space. 760px is what one full row costs: name + worktree
|
||||
pill + parent path + timestamp. */
|
||||
max-width: none;
|
||||
width: min(760px, calc(100vw - 24px));
|
||||
}
|
||||
.run-mode-history {
|
||||
/* More rows are worth showing once each one is legible. */
|
||||
max-height: 320px;
|
||||
}
|
||||
}
|
||||
.run-mode-menu.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4524,6 +4546,37 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
simply pinned the menu at its max-width. */
|
||||
.run-mode-history .run-mode-option {
|
||||
max-width: 100%;
|
||||
/* `.run-mode-history` is a block scroller, not a flex container, so these
|
||||
<button> rows are shrink-to-fit and ignore the menu's width. Without this
|
||||
the `flex: 1` + `text-align: right` on `.hist-dir` below have nothing to
|
||||
expand into: the path never right-aligns and widening the menu only adds
|
||||
empty space to its right. */
|
||||
width: 100%;
|
||||
}
|
||||
/* Recent-session row: identity first, path last.
|
||||
The row reads <folder> [⑂ worktree · branch] <parent path> <time>
|
||||
with only the parent path allowed to shrink, so truncation can never hide
|
||||
which project (or which worktree) a row refers to. */
|
||||
.run-mode-hist-row {
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.run-mode-option .hist-name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.run-mode-option .hist-wt {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.78em;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-mode-option .hist-dir {
|
||||
flex: 1;
|
||||
@@ -4534,6 +4587,11 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
/* The parent path is context, not identity — dim it and let it be the part
|
||||
that loses characters first. */
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
text-align: right;
|
||||
}
|
||||
.run-mode-option .hist-meta {
|
||||
font-size: 0.85em;
|
||||
@@ -14701,11 +14759,14 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* DOM order stays close-then-save so the focus trap lands on Close;
|
||||
row-reverse paints Save to the left of it. Below 860px, where Save joins the
|
||||
header, the two become ONE control cluster inside a tray (mobile.css). */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 3px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -14713,16 +14774,65 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
instead; on desktop the footer owns Save, so this stays hidden. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 30px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.01em;
|
||||
padding: 8px 15px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
|
||||
color: var(--accent-ink);
|
||||
transition:
|
||||
filter 0.15s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* `.modal-close` ships as a bare 1.5rem glyph with no box — fine in a plain
|
||||
modal header, but inside the tray it needs the same footprint as Save. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* The shared rule offsets the ring OUTWARD, which inside the tray draws it on
|
||||
top of the tray border. Inset it so the focused button is ringed, not the
|
||||
cluster. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-body {
|
||||
|
||||
+383
-35
@@ -32,6 +32,28 @@
|
||||
// short window, only the app's synthetic tap-to-position mouse event should
|
||||
// reach xterm.
|
||||
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
|
||||
// Finger travel (px) still counted as a tap rather than a scroll. Shared by
|
||||
// the terminal's own touch handling (TAP_THRESHOLD, initTerminal) and the
|
||||
// keyboard-dismiss handler (_installMobileKeyboardDismiss), which MUST agree:
|
||||
// a gesture the terminal treats as a scroll but the dismiss handler treats as
|
||||
// a tap would close the keyboard mid-scroll and drop the composer.
|
||||
const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8;
|
||||
// Regions where a tap must NOT dismiss the on-screen keyboard
|
||||
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
|
||||
// focus itself, and the accessory bar, which is built to be used while the
|
||||
// keyboard is open.
|
||||
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'button',
|
||||
'a[href]',
|
||||
'[contenteditable=""]',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'.keyboard-accessory-bar',
|
||||
'.path-picker-overlay',
|
||||
].join(',');
|
||||
// Escape sequences occupy no terminal cells, so they must come out before a
|
||||
// captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC,
|
||||
// CSI, charset designators and the short escapes tmux emits; deliberately
|
||||
@@ -53,6 +75,7 @@
|
||||
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
|
||||
// cap: a fling must not build a backlog that keeps paging after it stops.
|
||||
const PAGE_KEY_MAX_PER_BATCH = 3;
|
||||
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
|
||||
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
|
||||
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
|
||||
// H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional
|
||||
@@ -180,6 +203,9 @@
|
||||
KEY_PAGE_DOWN,
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
|
||||
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
@@ -650,7 +676,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
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
|
||||
let tapStartedWithTerminalFocus = false;
|
||||
let tapStartIntentCache = null;
|
||||
// px — ignore micro-drift to distinguish tap from scroll. Shared with the
|
||||
// keyboard-dismiss handler so both classify the same gesture the same way.
|
||||
const TAP_THRESHOLD = window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_TAP_SLOP;
|
||||
container.addEventListener(
|
||||
'touchstart',
|
||||
(ev) => {
|
||||
@@ -662,6 +692,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
didScroll = false;
|
||||
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
|
||||
// Classifying scans the whole viewport with translateToString, and
|
||||
// this runs at the start of EVERY gesture including scroll drags.
|
||||
// Cache the result for the touchend of this same gesture rather than
|
||||
// recomputing it; the cache is keyed on the exact start coordinates
|
||||
// so a finger that moved re-classifies at its real position.
|
||||
const touchStartIntent = this._classifyMobileTerminalTap(touchLastX, touchLastY);
|
||||
tapStartIntentCache = { x: touchLastX, y: touchLastY, intent: touchStartIntent };
|
||||
if (touchStartIntent === 'content') {
|
||||
// Cancel xterm/browser focus before the compatibility click can
|
||||
// open the OS keyboard. Content taps are re-emitted as SGR on
|
||||
// touchend.
|
||||
//
|
||||
// 'history' is deliberately NOT included. A scrolled-up viewport
|
||||
// sends nothing, so there is no compatibility click worth
|
||||
// cancelling — and preventDefault() here, paired with touchend's
|
||||
// early return, closes both routes to focus at once. Since
|
||||
// selectSession() ends with scrollToLastNonEmptyLine(), that made
|
||||
// the keyboard unreachable after every tab switch.
|
||||
ev.preventDefault();
|
||||
this._blurMobileTerminalInput();
|
||||
}
|
||||
lastTime = 0;
|
||||
if (scrollFrame) {
|
||||
cancelAnimationFrame(scrollFrame);
|
||||
@@ -669,7 +721,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
@@ -721,44 +773,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
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);
|
||||
} else if (touch && this._sessionUsesServerMouseStrip()) {
|
||||
// The server strips mouse-tracking DECSETs from claude/codex/gemini
|
||||
// output (isAltScreenStripMode, session.ts) so the wheel keeps
|
||||
// scrolling scrollback — which leaves THIS xterm permanently at
|
||||
// mouseTrackingMode 'none' even though the TUI on the PTY side has
|
||||
// tracking ON and still understands SGR reports. Encode the report
|
||||
// ourselves and send it straight to the PTY: no DOM click is
|
||||
// dispatched, so xterm's local selection can't trigger either.
|
||||
this._sendSyntheticSgrTap(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.terminal.focus();
|
||||
const cached =
|
||||
tapStartIntentCache &&
|
||||
tapStartIntentCache.x === touch.clientX &&
|
||||
tapStartIntentCache.y === touch.clientY
|
||||
? tapStartIntentCache.intent
|
||||
: null;
|
||||
this._handleMobileTerminalTap(touch, tapStartedWithTerminalFocus, cached);
|
||||
}
|
||||
}
|
||||
tapStartedWithTerminalFocus = false;
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
@@ -769,6 +796,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
isTouching = false;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
tapStartedWithTerminalFocus = false;
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
@@ -784,6 +812,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Hand-encode the SGR report for plain left-clicks on those sessions.
|
||||
container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev));
|
||||
|
||||
this._installMobileKeyboardDismiss();
|
||||
|
||||
// Welcome message
|
||||
this.showWelcome();
|
||||
|
||||
@@ -3373,6 +3403,324 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {}
|
||||
},
|
||||
|
||||
_isMobileTerminalInputFocused() {
|
||||
const active = document.activeElement;
|
||||
return (
|
||||
active === this.terminal?.textarea ||
|
||||
active?.classList?.contains('xterm-helper-textarea') ||
|
||||
active?.id === 'cjkInput'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Separate terminal input from TUI-owned content on touch devices. A hidden
|
||||
* keyboard must not consume taps on expandable readbacks, tool results, or
|
||||
* decision rows; those taps belong to the foreground CLI. The visible prompt
|
||||
* row remains the deliberate keyboard target.
|
||||
*/
|
||||
_classifyMobileTerminalTap(clientX, clientY) {
|
||||
if (!this._terminalViewportAtBottom()) return 'history';
|
||||
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
if (!pos || !this.terminal) return 'input';
|
||||
|
||||
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
||||
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
||||
if (!mouseTrackingOn && !this._sessionUsesServerMouseStrip()) return 'input';
|
||||
|
||||
const buffer = this.terminal.buffer?.active;
|
||||
if (!buffer?.getLine) return 'input';
|
||||
|
||||
const rows = Math.max(1, this.terminal.rows || 1);
|
||||
const lines = [];
|
||||
const wrappedRows = [];
|
||||
let hasVisibleContent = false;
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const line = buffer.getLine(buffer.viewportY + row);
|
||||
const text = line?.translateToString?.(true) || '';
|
||||
lines.push(text);
|
||||
wrappedRows.push(Boolean(line?.isWrapped));
|
||||
if (text.trim()) hasVisibleContent = true;
|
||||
}
|
||||
if (!hasVisibleContent) return 'input';
|
||||
|
||||
const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0));
|
||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
||||
let promptRow = -1;
|
||||
let menuSelectionVisible = false;
|
||||
|
||||
if (mode === 'opencode') {
|
||||
if (lines[cursorRow]?.includes('\u2503')) promptRow = cursorRow;
|
||||
} else {
|
||||
for (let row = rows - 1; row >= 0; row--) {
|
||||
const promptMatch = lines[row].match(/^\s*[❯›]/);
|
||||
if (!promptMatch) continue;
|
||||
const tail = lines[row].slice(promptMatch[0].length).trim();
|
||||
// A highlighted numbered choice is a menu row, not an editable prompt.
|
||||
const hasSiblingChoice = lines.some(
|
||||
(line, choiceRow) => choiceRow !== row && /^\s+\d+[.)]\s/.test(line)
|
||||
);
|
||||
if (/^\d+[.)]\s/.test(tail) && hasSiblingChoice) {
|
||||
menuSelectionVisible = true;
|
||||
break;
|
||||
}
|
||||
promptRow = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const tappedRow = pos.row - 1;
|
||||
let logicalLineStart = tappedRow;
|
||||
while (logicalLineStart > 0 && wrappedRows[logicalLineStart]) logicalLineStart--;
|
||||
let logicalLineEnd = tappedRow;
|
||||
while (logicalLineEnd + 1 < rows && wrappedRows[logicalLineEnd + 1]) logicalLineEnd++;
|
||||
const tappedLine = lines.slice(logicalLineStart, logicalLineEnd + 1).join('');
|
||||
// Claude's status row is TUI-owned: tapping it opens the teammate view, so it
|
||||
// must not be treated as a keyboard target. Match the AFFORDANCE, not the
|
||||
// wording — the bullet and verb are both unstable (claude 2.1.226 prints
|
||||
// "✻ Cooked for 2m 6s", "✻ Baked for 9m 47s"; earlier builds printed
|
||||
// "• Working …"), while "esc to interrupt" / "background" are what make the
|
||||
// row actionable in the first place.
|
||||
if (mode === 'claude' && /\b(?:esc to interrupt|background)\b/i.test(tappedLine)) {
|
||||
return 'content';
|
||||
}
|
||||
if (menuSelectionVisible) return 'content';
|
||||
if (promptRow >= 0) {
|
||||
const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow;
|
||||
if (tappedRow >= promptRow && tappedRow <= inputEnd) return 'input';
|
||||
} else if (
|
||||
tappedRow === cursorRow ||
|
||||
tappedRow >=
|
||||
Math.max(
|
||||
0,
|
||||
rows -
|
||||
window.CodemanTerminalInput
|
||||
.TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM
|
||||
)
|
||||
) {
|
||||
// During redraws a CLI can temporarily omit its prompt marker or place
|
||||
// the cursor above a status footer. Keep the live cursor and a stable
|
||||
// lower-screen focus band usable without turning transcript rows above
|
||||
// that band into keyboard targets.
|
||||
return 'input';
|
||||
}
|
||||
|
||||
return 'content';
|
||||
},
|
||||
|
||||
_blurMobileTerminalInput() {
|
||||
const active = document.activeElement;
|
||||
if (
|
||||
active === this.terminal?.textarea ||
|
||||
active?.classList?.contains('xterm-helper-textarea') ||
|
||||
active?.id === 'cjkInput'
|
||||
) {
|
||||
active.blur?.();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tapping outside the terminal closes the on-screen keyboard.
|
||||
*
|
||||
* The terminal keeps focus on a hidden textarea, and nothing ever released it:
|
||||
* once the keyboard was up, every tap on the header, the tab strip or empty
|
||||
* page chrome left it up, covering half a phone screen with no way to dismiss
|
||||
* it but the OS back gesture.
|
||||
*
|
||||
* Deliberately narrow, because focus is not ours to steal:
|
||||
*
|
||||
* - only when the terminal input actually holds focus;
|
||||
* - never for a tap inside the terminal — those are classified and routed by
|
||||
* `_handleMobileTerminalTap`, which owns that decision;
|
||||
* - never for a tap on another control. Anything focusable or clickable is
|
||||
* about to take focus itself, and the accessory bar in particular exists to
|
||||
* be used WHILE the keyboard is open, so dismissing there would fight the
|
||||
* user. `closest()` covers taps landing on a child (an icon inside a button).
|
||||
*
|
||||
* Bound to `touchend` rather than `click`: a tap that dismisses the keyboard
|
||||
* usually is not meant to activate whatever is underneath, and touchend fires
|
||||
* before the synthesized click, so the blur lands first.
|
||||
*/
|
||||
_installMobileKeyboardDismiss() {
|
||||
if (this._mobileKeyboardDismissHandler) return;
|
||||
|
||||
// A SCROLL also ends in touchend, and dismissing there is wrong: scrolling
|
||||
// to read something while composing must not close the keyboard and lose
|
||||
// the composer. Track how far the finger travelled and only treat a
|
||||
// near-stationary gesture as a tap — the same TAP_THRESHOLD the terminal's
|
||||
// own touch handling uses, so both agree on what a tap is.
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let moved = false;
|
||||
this._mobileKeyboardDismissStart = (ev) => {
|
||||
if (ev.touches.length !== 1) {
|
||||
moved = true; // a multi-touch gesture is never a dismissing tap
|
||||
return;
|
||||
}
|
||||
startX = ev.touches[0].clientX;
|
||||
startY = ev.touches[0].clientY;
|
||||
moved = false;
|
||||
};
|
||||
this._mobileKeyboardDismissMove = (ev) => {
|
||||
if (moved || !ev.touches.length) return;
|
||||
const dx = ev.touches[0].clientX - startX;
|
||||
const dy = ev.touches[0].clientY - startY;
|
||||
const slop = window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_TAP_SLOP;
|
||||
if (Math.abs(dx) > slop || Math.abs(dy) > slop) {
|
||||
moved = true;
|
||||
}
|
||||
};
|
||||
this._mobileKeyboardDismissHandler = (ev) => {
|
||||
if (moved) return;
|
||||
if (!this._isMobileTerminalInputFocused()) return;
|
||||
const target = ev.target;
|
||||
if (!target || typeof target.closest !== 'function') return;
|
||||
if (target.closest('#terminalContainer')) return;
|
||||
if (target.closest(window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR)) return;
|
||||
this._blurMobileTerminalInput();
|
||||
};
|
||||
// Passive throughout: this never calls preventDefault, so it must not make
|
||||
// the page feel less responsive to scrolling.
|
||||
document.addEventListener('touchstart', this._mobileKeyboardDismissStart, { passive: true });
|
||||
document.addEventListener('touchmove', this._mobileKeyboardDismissMove, { passive: true });
|
||||
document.addEventListener('touchend', this._mobileKeyboardDismissHandler, { passive: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Which 'content' taps should DISMISS the mobile keyboard. Expandable
|
||||
* readbacks, tool results and decision rows are TUI-owned: tapping them acts
|
||||
* on the CLI, so popping the keyboard there is wrong. An inert transcript row
|
||||
* still sends its mouse report, but must keep the keyboard reachable —
|
||||
* touchstart's preventDefault cancels the compatibility click that would
|
||||
* otherwise focus xterm, so focus has to be restored explicitly.
|
||||
*/
|
||||
_isActionableMobileTerminalTap(clientX, clientY) {
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
const buffer = this.terminal?.buffer?.active;
|
||||
if (!pos || !buffer?.getLine) return false;
|
||||
|
||||
const rows = Math.max(1, this.terminal.rows || 1);
|
||||
const lines = [];
|
||||
const wrappedRows = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const line = buffer.getLine(buffer.viewportY + row);
|
||||
lines.push(line?.translateToString?.(true) || '');
|
||||
wrappedRows.push(Boolean(line?.isWrapped));
|
||||
}
|
||||
|
||||
const tappedRow = pos.row - 1;
|
||||
let logicalLineStart = tappedRow;
|
||||
while (logicalLineStart > 0 && wrappedRows[logicalLineStart]) logicalLineStart--;
|
||||
let logicalLineEnd = tappedRow;
|
||||
while (logicalLineEnd + 1 < rows && wrappedRows[logicalLineEnd + 1]) logicalLineEnd++;
|
||||
const tappedLine = lines.slice(logicalLineStart, logicalLineEnd + 1).join('');
|
||||
|
||||
// Match the AFFORDANCE a CLI prints, not the row's title text: an
|
||||
// expandable readback, tool result or status row advertises how to act on
|
||||
// it ("ctrl+r to expand", "tap to collapse", "esc to interrupt"). Keying on
|
||||
// titles instead would only recognise the exact strings a fixture happens
|
||||
// to use, and would let a real readback keep the keyboard open.
|
||||
//
|
||||
// The hint sits on its own row, so a readback's TITLE row — the one a
|
||||
// finger actually lands on — carries no affordance text itself. Look at the
|
||||
// adjacent row too, which is how these blocks are laid out in practice.
|
||||
// Keyed on the ACTION VERB, and deliberately not on prose verbs. A CLI hint
|
||||
// names a key or a gesture ("ctrl+r to expand", "tap to collapse",
|
||||
// "esc to interrupt"); "click here to open the file" is transcript content
|
||||
// and must keep the keyboard, so `click` and bare `here` are excluded.
|
||||
// The hint may sit mid-line — Claude's status row is
|
||||
// "✻ Cooked for 2m 6s · esc to interrupt" — so this is not anchored.
|
||||
const affordance =
|
||||
/\b(?:ctrl\+\w+|shift\+\w+|esc|enter|tab|tap)\s+to\s+(?:expand|collapse|view|open|interrupt|see)\b/i;
|
||||
const blockStart = Math.max(0, logicalLineStart - 1);
|
||||
const blockEnd = Math.min(rows - 1, logicalLineEnd + 1);
|
||||
for (let row = blockStart; row <= blockEnd; row++) {
|
||||
if (affordance.test(lines[row])) return true;
|
||||
}
|
||||
// A Claude status row ("✻ Cooked for 2m 6s · esc to interrupt") is caught by
|
||||
// the affordance above; there is deliberately no verb literal here, because
|
||||
// the verb is randomised per build.
|
||||
|
||||
// A visible selection dialog makes its OWN rows actionable, not the whole
|
||||
// screen. Two viewport-wide `some()` tests used to be the entire answer, so
|
||||
// while a Claude question or permission dialog was up EVERY tap in the
|
||||
// terminal (inert transcript, the question title, blank rows) came back
|
||||
// actionable, and the caller blurred on each one. The on-screen keyboard
|
||||
// could then not be opened at all until the dialog was answered, which left
|
||||
// tapping an option row as the only interaction available: the one that
|
||||
// commits an answer. Requiring the TAPPED line to be a numbered row keeps
|
||||
// the dialog's own rows behaving as before (report the tap, keep the
|
||||
// keyboard down) while any other row can still summon the keyboard, which
|
||||
// is how a digit gets typed at a dialog instead of aimed at it.
|
||||
const hasMenuPrompt = lines.some((line) => /^\s*[❯›]\s+\d+[.)]\s/.test(line));
|
||||
const hasMenuChoice = lines.some((line) => /^\s+\d+[.)]\s/.test(line));
|
||||
return hasMenuPrompt && hasMenuChoice && /^\s*(?:[❯›]\s*)?\d+[.)]\s/.test(tappedLine);
|
||||
},
|
||||
|
||||
_focusMobileTerminalInput() {
|
||||
this._syncMobileHelperTextareaToCursor();
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.focus();
|
||||
} else {
|
||||
this.terminal?.focus();
|
||||
}
|
||||
},
|
||||
|
||||
_handleMobileTerminalTap(touch, startedWithTerminalFocus, cachedIntent = null) {
|
||||
// A guard bail-out, not a classification: there is nothing to classify. It is
|
||||
// deliberately NOT 'history', which would claim the viewport was scrolled up.
|
||||
if (!touch || !this.terminal) return null;
|
||||
// touchstart already classified this exact point; reuse it rather than paying
|
||||
// a second full-viewport scan for the same gesture.
|
||||
const intent = cachedIntent ?? this._classifyMobileTerminalTap(touch.clientX, touch.clientY);
|
||||
if (intent === 'history') {
|
||||
// Scrolled up: send NO mouse report — a tap on old output must not be
|
||||
// delivered to the CLI as a click on whatever row now occupies that cell.
|
||||
// Focus is a separate question, and the answer is yes: the user tapped the
|
||||
// terminal, so let them type. Blurring here stranded activeElement on
|
||||
// <body> with no way back to the keyboard.
|
||||
this._focusMobileTerminalInput();
|
||||
return intent;
|
||||
}
|
||||
|
||||
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
||||
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
||||
const shouldActivate = intent === 'content' || startedWithTerminalFocus;
|
||||
if (shouldActivate && mouseTrackingOn) {
|
||||
// xterm's mouse encoder owns live DECSET modes. The synthetic DOM click
|
||||
// follows the same path as a desktop click.
|
||||
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
||||
} else if (shouldActivate && this._sessionUsesServerMouseStrip()) {
|
||||
// Claude/Codex/Gemini DECSETs are stripped from the browser stream, so
|
||||
// report directly to the PTY while retaining local touch scrollback.
|
||||
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
if (intent === 'content' && this._isActionableMobileTerminalTap(touch.clientX, touch.clientY)) {
|
||||
// A synthetic xterm click can focus its helper textarea. Blur after the
|
||||
// report so collapsing a readback never opens or retains the keyboard.
|
||||
this._blurMobileTerminalInput();
|
||||
} else if (intent === 'content' && startedWithTerminalFocus) {
|
||||
// Tapping INERT transcript with the keyboard already up closes it.
|
||||
//
|
||||
// Every terminal tap re-focuses, so once the keyboard is open the only way
|
||||
// to close it is the accessory bar's dismiss chevron. Tapping the
|
||||
// transcript to get the screen back is the obvious gesture, and nothing
|
||||
// else claims it: an inert row has no action to trigger, so by this point
|
||||
// the tap has already done its only other job (the mouse report above).
|
||||
//
|
||||
// Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps
|
||||
// focus-then-position, so a second tap there still places the caret —
|
||||
// pinned by "keeps the first prompt tap focus-only so it cannot activate a
|
||||
// CLI row". Toggling there would trade away real capability.
|
||||
this._blurMobileTerminalInput();
|
||||
} else {
|
||||
this._focusMobileTerminalInput();
|
||||
}
|
||||
return intent;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Synthetic tap → mouse report
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -2619,6 +2619,12 @@ export class WebServer extends EventEmitter {
|
||||
workingDir: muxSession.workingDir,
|
||||
mode: muxSession.mode,
|
||||
name: sessionName,
|
||||
// When the session FIRST started, not when this server booted.
|
||||
// Without it every recovered session was restamped `Date.now()` on
|
||||
// each restart, so a week-old pane read as "created 2m ago" on the
|
||||
// home screens (and sorted as the newest thing in the unified list).
|
||||
// mux-sessions.json carries the tmux session's own birth time.
|
||||
createdAt: muxSession.createdAt || savedState?.createdAt,
|
||||
mux: this.mux,
|
||||
useMux: true,
|
||||
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
||||
|
||||
@@ -211,6 +211,60 @@ describe('mobile overview model', () => {
|
||||
expect(empty).toMatchObject({ needsYou: [], current: [], past: [], sessionCount: 0 });
|
||||
});
|
||||
|
||||
it('anchors the "how long" stamp on last activity, and on the last Enter while working', () => {
|
||||
const app = loadOverviewApp();
|
||||
const now = Date.now();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [
|
||||
// A working pane repaints about once a second, so lastActivityAt is
|
||||
// always "now" and would report every running turn as 0m. The turn's
|
||||
// own start is the last Enter.
|
||||
session({
|
||||
id: 'w',
|
||||
status: 'busy',
|
||||
createdAt: now - 7200_000,
|
||||
lastActivityAt: now,
|
||||
lastSubmitAt: now - 300_000,
|
||||
}),
|
||||
// A quiet pane prints nothing, so its last byte IS when it went idle.
|
||||
session({ id: 'i', status: 'idle', createdAt: now - 7200_000, lastActivityAt: now - 900_000 }),
|
||||
],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r]));
|
||||
expect(rows.w.since).toEqual({ key: 'working', at: now - 300_000 });
|
||||
expect(rows.i.since).toEqual({ key: 'idle', at: now - 900_000 });
|
||||
expect(rows.i.createdAt).toBe(now - 7200_000);
|
||||
});
|
||||
|
||||
it('leaves the stamp off rather than inventing an anchor', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
// A session that has never submitted has no turn start to measure from.
|
||||
sessions: [session({ id: 'w', status: 'busy', lastActivityAt: Date.now() })],
|
||||
cases: CASES,
|
||||
});
|
||||
expect(model.current[0].since).toBeNull();
|
||||
expect(model.current[0].createdAt).toBe(0);
|
||||
});
|
||||
|
||||
it('formats a moment as "ago" and a span as a bare duration', () => {
|
||||
const app = loadOverviewApp();
|
||||
app.formatRelativeTime = () => '3d ago';
|
||||
const now = Date.now();
|
||||
|
||||
expect(app._mobileOverviewStampText(now - 86_400_000, 'ago')).toBe('3d ago');
|
||||
expect(app._mobileOverviewStampText(now - 20_000, 'for')).toBe('<1m');
|
||||
expect(app._mobileOverviewStampText(now - 12 * 60_000, 'for')).toBe('12m');
|
||||
expect(app._mobileOverviewStampText(now - 125 * 60_000, 'for')).toBe('2h 5m');
|
||||
expect(app._mobileOverviewStampText(now - 3 * 3600_000, 'for')).toBe('3h');
|
||||
expect(app._mobileOverviewStampText(now - 50 * 3600_000, 'for')).toBe('2d 2h');
|
||||
// No anchor renders as a dash, never as "56 years ago" off epoch 0.
|
||||
expect(app._mobileOverviewStampText(0, 'for')).toBe('—');
|
||||
expect(app._mobileOverviewStampText(0, 'ago')).toBe('—');
|
||||
});
|
||||
|
||||
it('no longer builds a spaces section', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({ sessions: [session({ id: 'a' })], cases: CASES });
|
||||
|
||||
@@ -573,3 +573,70 @@ describe('armed styling survives the light-skin overrides', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer nav keys from the bar', () => {
|
||||
/** loadBar()'s app stub plus the local-echo state the flush path reads. */
|
||||
function barWithDraft(draft: string) {
|
||||
const loaded = loadBar('claude');
|
||||
const overlay = {
|
||||
pendingText: draft,
|
||||
clear: vi.fn(() => {
|
||||
overlay.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
const app = loaded.app as unknown as Record<string, unknown>;
|
||||
app._localEchoEnabled = true;
|
||||
app._localEchoOverlay = overlay;
|
||||
app.sendInput = vi.fn();
|
||||
app._flushedOffsets = new Map([['session-1', 3]]);
|
||||
app._flushedTexts = new Map([['session-1', 'dra']]);
|
||||
return { ...loaded, app, overlay };
|
||||
}
|
||||
|
||||
const sentKeys = (fetchMock: { mock: { calls: unknown[][] } }) =>
|
||||
fetchMock.mock.calls.map((call) => JSON.parse((call[1] as { body: string }).body).input);
|
||||
|
||||
it('flushes the unsent draft before sending the arrow', () => {
|
||||
// On a phone the typed text lives in the overlay and has NEVER reached the
|
||||
// PTY, so an arrow sent on its own arrives at a composer the CLI still
|
||||
// considers empty: Up recalls a history entry into it while the overlay
|
||||
// goes on painting the draft over the same row and still believes it is
|
||||
// pending. Flushing first is also what makes the draft recoverable: the
|
||||
// CLI stashes the live composer and hands it back on Down.
|
||||
const { bar, app, overlay, fetchMock } = barWithDraft('draft I typed');
|
||||
|
||||
bar.handleAction('scroll-up');
|
||||
|
||||
expect(app.sendInput).toHaveBeenCalledWith('draft I typed');
|
||||
expect(sentKeys(fetchMock)).toEqual(['\x1b[A']);
|
||||
expect(overlay.pendingText).toBe('');
|
||||
expect(overlay.suppressBufferDetection).toHaveBeenCalled();
|
||||
// The overlay's bookkeeping for this session has to go with it.
|
||||
expect((app._flushedOffsets as Map<string, number>).has('session-1')).toBe(false);
|
||||
expect((app._flushedTexts as Map<string, string>).has('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('hands the session to plain PTY echo, like a typed nav key does', () => {
|
||||
// After a nav key the real cursor can sit mid-text, where the overlay's
|
||||
// append-only buffering cannot track edits (issue #218). terminal-ui.js's
|
||||
// onData branch does exactly this for a nav key typed on a keyboard.
|
||||
const { bar, app } = barWithDraft('');
|
||||
|
||||
bar.handleAction('arrow-left');
|
||||
|
||||
expect([...(app._echoPassthroughSessions as Set<string>)]).toEqual(['session-1']);
|
||||
});
|
||||
|
||||
it('sends all four arrows and skips the flush when there is no draft', () => {
|
||||
const { bar, app, fetchMock } = barWithDraft('');
|
||||
|
||||
bar.handleAction('scroll-up');
|
||||
bar.handleAction('scroll-down');
|
||||
bar.handleAction('arrow-left');
|
||||
bar.handleAction('arrow-right');
|
||||
|
||||
expect(sentKeys(fetchMock)).toEqual(['\x1b[A', '\x1b[B', '\x1b[D', '\x1b[C']);
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -624,6 +624,122 @@ describe('Virtual Keyboard', () => {
|
||||
expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('dismisses the on-screen keyboard when a tap lands outside the terminal', async () => {
|
||||
// The terminal holds focus on a hidden textarea and nothing released it,
|
||||
// so once the keyboard was up every tap on the header or page chrome left
|
||||
// it up — covering half a phone screen with no in-app way to close it.
|
||||
//
|
||||
// Driven as a real dispatched gesture: the handler is bound to touchend on
|
||||
// document, and calling the internal helper would bypass the routing this
|
||||
// test exists to check.
|
||||
const result = await page.evaluate(async () => {
|
||||
const sampleX = (rect: DOMRect) => Math.max(2, rect.left + Math.min(6, rect.width / 2));
|
||||
const sampleY = (rect: DOMRect) => Math.max(2, rect.top + Math.min(6, rect.height / 2));
|
||||
const tap = async (el: Element, travel = 0, point?: { x: number; y: number }) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = point ? point.x : sampleX(rect);
|
||||
const y = point ? point.y : sampleY(rect);
|
||||
const target = document.elementFromPoint(x, y) || el;
|
||||
const at = (cy: number) => new Touch({ identifier: 21, target, clientX: x, clientY: cy });
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchstart', {
|
||||
touches: [at(y)],
|
||||
targetTouches: [at(y)],
|
||||
changedTouches: [at(y)],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
for (const step of travel ? [travel / 3, (travel * 2) / 3, travel] : []) {
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchmove', {
|
||||
touches: [at(y + step)],
|
||||
targetTouches: [at(y + step)],
|
||||
changedTouches: [at(y + step)],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchend', {
|
||||
touches: [],
|
||||
changedTouches: [at(y + travel)],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return document.activeElement?.className ?? '';
|
||||
};
|
||||
|
||||
app.hideWelcome();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) => app.terminal.write('transcript\r\n\r\n> ', resolve));
|
||||
|
||||
// Inert page chrome: the keyboard must close.
|
||||
app._focusMobileTerminalInput();
|
||||
const focusedBefore = document.activeElement?.className ?? '';
|
||||
const afterOutside = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body);
|
||||
|
||||
// A real control: it takes focus itself, so we must NOT interfere.
|
||||
app._focusMobileTerminalInput();
|
||||
const button = Array.from(document.querySelectorAll('button:not([disabled])')).find((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
if (rect.width <= 8 || rect.height <= 8) return false;
|
||||
// A rect is not enough. The welcome overlay is hidden by hideWelcome()
|
||||
// above but its buttons still MEASURE, so a rect-only pick sampled a
|
||||
// point the terminal actually owns — elementFromPoint returned
|
||||
// .xterm-screen and this case tapped the terminal instead of a
|
||||
// control, passing for the wrong reason. Require the sampled point to
|
||||
// really resolve to this button.
|
||||
const hit = document.elementFromPoint(sampleX(rect), sampleY(rect));
|
||||
return !!hit && candidate.contains(hit);
|
||||
});
|
||||
const afterButton = button ? await tap(button) : 'no-visible-button';
|
||||
|
||||
// Inside the terminal, tap classification owns the decision, so this
|
||||
// handler must keep its hands off. Aimed at the PROMPT row: that is the
|
||||
// one in-terminal tap whose outcome belongs to nobody else, since an
|
||||
// inert transcript row is claimed by the in-terminal dismiss toggle
|
||||
// (`toggles the keyboard shut on a second inert Claude transcript tap`)
|
||||
// and asserting focus there would be asserting that toggle's behaviour
|
||||
// rather than this exemption. The guard still bites: the container's own
|
||||
// touchend listener runs first and refocuses, so a missing
|
||||
// #terminalContainer exemption would blur right back over it.
|
||||
app._focusMobileTerminalInput();
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const screenRect = screen?.getBoundingClientRect();
|
||||
const promptPoint =
|
||||
screenRect && cell?.width && cell?.height
|
||||
? {
|
||||
x: screenRect.left + cell.width * 2,
|
||||
y: screenRect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
|
||||
}
|
||||
: undefined;
|
||||
const afterTerminal = await tap(document.querySelector('#terminalContainer')!, 0, promptPoint);
|
||||
|
||||
// A SCROLL also ends in touchend. Scrolling to read something while
|
||||
// composing must not close the keyboard and drop the composer.
|
||||
app._focusMobileTerminalInput();
|
||||
const afterScroll = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body, 120);
|
||||
|
||||
return { focusedBefore, afterOutside, afterButton, afterTerminal, afterScroll };
|
||||
});
|
||||
|
||||
expect(result.focusedBefore).toContain('xterm-helper-textarea');
|
||||
// Red on master: the textarea keeps focus and the keyboard stays up.
|
||||
expect(result.afterOutside).not.toContain('xterm-helper-textarea');
|
||||
expect(result.afterButton).not.toBe('no-visible-button');
|
||||
expect(result.afterButton).toContain('xterm-helper-textarea');
|
||||
expect(result.afterTerminal).toContain('xterm-helper-textarea');
|
||||
// A scroll ends in touchend too, and must NOT close the keyboard.
|
||||
expect(result.afterScroll).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
it('routes CJK textarea typing through local echo on Enter', async () => {
|
||||
await page.evaluate(() => {
|
||||
window.__sentInputs = [];
|
||||
@@ -845,6 +961,287 @@ describe('Virtual Keyboard', () => {
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('collapses a terminal readback without focusing the hidden textarea', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-readback-tap-test';
|
||||
app.sessions.set('mobile-readback-tap-test', {
|
||||
id: 'mobile-readback-tap-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve)
|
||||
);
|
||||
app.terminal.focus();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height / 2,
|
||||
};
|
||||
});
|
||||
expect(point).not.toBeNull();
|
||||
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).not.toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toHaveLength(1);
|
||||
expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/);
|
||||
});
|
||||
|
||||
it('toggles the keyboard shut on a second inert Claude transcript tap', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-claude-transcript-tap-test';
|
||||
app.sessions.set('mobile-claude-transcript-tap-test', {
|
||||
id: 'mobile-claude-transcript-tap-test',
|
||||
mode: 'claude',
|
||||
cliVersion: '2.1.220',
|
||||
status: 'working',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write(
|
||||
'Transcript row one\r\nTranscript row two\r\nTranscript row three\r\nTranscript row four\r\nTranscript row five\r\nTranscript row six\r\nTranscript row seven\r\nTranscript row eight\r\nTranscript row nine\r\nTranscript row ten\r\n\r\n❯ ',
|
||||
resolve
|
||||
)
|
||||
);
|
||||
app.terminal.focus();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!screen || !rect || !cell?.width || !cell?.height) return null;
|
||||
const cursorRow = app.terminal.buffer.active.cursorY;
|
||||
const transcriptRow = Math.max(1, Math.floor(cursorRow / 2));
|
||||
const x = rect.left + cell.width * 2;
|
||||
const y = rect.top + cell.height * (transcriptRow + 0.5);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
intent: app._classifyMobileTerminalTap(x, y),
|
||||
activeClass: document.activeElement?.className,
|
||||
};
|
||||
});
|
||||
expect(point).toEqual(
|
||||
expect.objectContaining({
|
||||
intent: 'content',
|
||||
activeClass: expect.stringContaining('xterm-helper-textarea'),
|
||||
})
|
||||
);
|
||||
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
// The setup above leaves the terminal focused, so this tap is the SECOND
|
||||
// one on an inert row — the case that now closes the keyboard. Previously
|
||||
// it re-focused, which left the accessory bar's chevron as the only way to
|
||||
// dismiss. The prompt row is unaffected and still positions the caret.
|
||||
const activeClass = await page.evaluate(() => document.activeElement?.className);
|
||||
expect(activeClass).not.toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-claude-subagent-tap-test';
|
||||
app.sessions.set('mobile-claude-subagent-tap-test', {
|
||||
id: 'mobile-claude-subagent-tap-test',
|
||||
mode: 'claude',
|
||||
cliVersion: '2.1.220',
|
||||
status: 'working',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
app.terminal.reset();
|
||||
const statusRow = Math.max(0, app.terminal.rows - 2);
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write(
|
||||
`${'\r\n'.repeat(statusRow)}• Working (1m 50s • esc to interrupt) · 1 background teammate`,
|
||||
resolve
|
||||
)
|
||||
);
|
||||
app.terminal.focus();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!screen || !rect || !cell?.width || !cell?.height) return null;
|
||||
const cursorRow = app.terminal.buffer.active.cursorY;
|
||||
const x = rect.left + cell.width * 2;
|
||||
const y = rect.top + cell.height * (cursorRow + 0.5);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
intent: app._classifyMobileTerminalTap(x, y),
|
||||
cursorRow,
|
||||
screenBottom: rect.bottom,
|
||||
};
|
||||
});
|
||||
expect(point).toEqual(
|
||||
expect.objectContaining({
|
||||
intent: 'content',
|
||||
})
|
||||
);
|
||||
|
||||
const dispatch = await page.evaluate(({ x, y }) => {
|
||||
const target = document.querySelector('#terminalContainer .xterm-screen');
|
||||
if (!(target instanceof Element)) {
|
||||
return { prevented: false, insideTerminal: false, targetClass: null };
|
||||
}
|
||||
const touch = new Touch({
|
||||
identifier: 3,
|
||||
target,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pageX: x,
|
||||
pageY: y,
|
||||
});
|
||||
const allowed = target.dispatchEvent(
|
||||
new TouchEvent('touchstart', {
|
||||
touches: [touch],
|
||||
changedTouches: [touch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchend', {
|
||||
touches: [],
|
||||
changedTouches: [touch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
return {
|
||||
prevented: !allowed,
|
||||
insideTerminal: Boolean(target.closest('#terminalContainer')),
|
||||
targetClass: target.className,
|
||||
};
|
||||
}, point!);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(dispatch).toEqual(
|
||||
expect.objectContaining({
|
||||
prevented: true,
|
||||
insideTerminal: true,
|
||||
})
|
||||
);
|
||||
expect(state.activeClass).not.toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('focuses the terminal helper textarea when the visible prompt is tapped', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-focus-visible-input-test';
|
||||
app.sessions.set('mobile-focus-visible-input-test', {
|
||||
id: 'mobile-focus-visible-input-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve)
|
||||
);
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
|
||||
};
|
||||
});
|
||||
expect(point).not.toBeNull();
|
||||
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('focuses the live Claude cursor when a redraw omits the prompt glyph', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-focus-promptless-claude-test';
|
||||
app.sessions.set('mobile-focus-promptless-claude-test', {
|
||||
id: 'mobile-focus-promptless-claude-test',
|
||||
mode: 'claude',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) => app.terminal.write('Claude response\r\nready for input', resolve));
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
|
||||
};
|
||||
});
|
||||
expect(point).not.toBeNull();
|
||||
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
|
||||
const calls = await page.evaluate(async () => {
|
||||
app.activeSessionId = 'mobile-touch-scroll-test';
|
||||
|
||||
@@ -6,8 +6,17 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
function loadTerminalUiHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
let now = 1_000;
|
||||
let keyboardVisible = false;
|
||||
let activeElement: unknown = null;
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
document: {
|
||||
body: { classList: { contains: () => false } },
|
||||
get activeElement() {
|
||||
return activeElement;
|
||||
},
|
||||
getElementById: () => null,
|
||||
},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
@@ -25,6 +34,11 @@ function loadTerminalUiHarness() {
|
||||
MobileDetection: {
|
||||
isTouchDevice: () => true,
|
||||
},
|
||||
KeyboardHandler: {
|
||||
get keyboardVisible() {
|
||||
return keyboardVisible;
|
||||
},
|
||||
},
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
@@ -38,6 +52,12 @@ function loadTerminalUiHarness() {
|
||||
setNow: (value: number) => {
|
||||
now = value;
|
||||
},
|
||||
setKeyboardVisible: (visible: boolean) => {
|
||||
keyboardVisible = visible;
|
||||
},
|
||||
setActiveElement: (element: unknown) => {
|
||||
activeElement = element;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +75,198 @@ function createElementHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new Set<number>()) {
|
||||
const textarea = {
|
||||
classList: { contains: (name: string) => name === 'xterm-helper-textarea' },
|
||||
blur: vi.fn(),
|
||||
};
|
||||
return {
|
||||
cols: 80,
|
||||
rows: lines.length,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: 0,
|
||||
baseY: 0,
|
||||
cursorY,
|
||||
getLine: (row: number) =>
|
||||
row >= 0 && row < lines.length
|
||||
? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
element: {
|
||||
querySelector: (selector: string) =>
|
||||
selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null,
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
textarea,
|
||||
focus: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('terminal touch tap mouse guard', () => {
|
||||
it('recognizes focus only when a terminal input owns the active element', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
const textarea = { classList: { contains: () => true } };
|
||||
app.terminal = { textarea };
|
||||
|
||||
setActiveElement(null);
|
||||
expect(app._isMobileTerminalInputFocused()).toBe(false);
|
||||
|
||||
setActiveElement(textarea);
|
||||
expect(app._isMobileTerminalInputFocused()).toBe(true);
|
||||
});
|
||||
|
||||
it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 1)).toBe('content'); // inline marker is not a prompt
|
||||
expect(app._classifyMobileTerminalTap(9, 17)).toBe('content'); // row 2: readback
|
||||
expect(app._classifyMobileTerminalTap(9, 65)).toBe('input'); // row 5: prompt
|
||||
expect(app._classifyMobileTerminalTap(9, 81)).toBe('content'); // row 6: status
|
||||
});
|
||||
|
||||
it('classifies Claude background-agent status as content rather than keyboard input', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''],
|
||||
4,
|
||||
new Set([4])
|
||||
);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 65)).toBe('content');
|
||||
});
|
||||
|
||||
it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('input');
|
||||
expect(app._classifyMobileTerminalTap(9, 1)).toBe('content');
|
||||
});
|
||||
|
||||
it('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('content');
|
||||
expect(app._classifyMobileTerminalTap(9, 49)).toBe('content');
|
||||
});
|
||||
|
||||
it('keeps the keyboard reachable while a selection dialog is on screen', () => {
|
||||
// The lock this pins: a visible dialog used to make EVERY row of the
|
||||
// terminal "actionable" (both menu tests scanned the whole viewport), so
|
||||
// every tap blurred and the on-screen keyboard could not be opened until
|
||||
// the dialog was answered, leaving tapping an option (the one gesture that
|
||||
// commits an answer) as the only thing a phone could do.
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''],
|
||||
2
|
||||
);
|
||||
app._sendInputAsync = vi.fn();
|
||||
setActiveElement(null);
|
||||
|
||||
// The dialog's own rows stay TUI-owned: report the tap, keep the keyboard down.
|
||||
expect(app._isActionableMobileTerminalTap(9, 33)).toBe(true); // ❯ 1. Yes
|
||||
expect(app._isActionableMobileTerminalTap(9, 49)).toBe(true); // 2. No, …
|
||||
// Everything else is inert, and must still be able to summon the keyboard.
|
||||
expect(app._isActionableMobileTerminalTap(9, 1)).toBe(false); // question title
|
||||
expect(app._isActionableMobileTerminalTap(9, 65)).toBe(false); // blank row
|
||||
|
||||
app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false);
|
||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||
|
||||
app.terminal.focus.mockClear();
|
||||
app._handleMobileTerminalTap({ clientX: 9, clientY: 33 }, false);
|
||||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('collapses TUI readback content without opening or retaining the keyboard', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
);
|
||||
app._sendInputAsync = vi.fn();
|
||||
setActiveElement(app.terminal.textarea);
|
||||
|
||||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 17 }, true)).toBe('content');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('sess-1', '\x1b[<0;2;2M\x1b[<0;2;2m');
|
||||
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
|
||||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the first prompt tap focus-only so it cannot activate a CLI row', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
);
|
||||
app._sendInputAsync = vi.fn();
|
||||
setActiveElement(null);
|
||||
|
||||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, false)).toBe('input');
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('closes the keyboard on a second tap of INERT transcript content', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
|
||||
app._sendInputAsync = vi.fn();
|
||||
|
||||
// Keyboard DOWN: the tap opens it.
|
||||
setActiveElement(null);
|
||||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false)).toBe('content');
|
||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();
|
||||
|
||||
// Keyboard UP on the same inert row: the tap closes it.
|
||||
app.terminal.focus.mockClear();
|
||||
setActiveElement(app.terminal.textarea);
|
||||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, true)).toBe('content');
|
||||
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
|
||||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the prompt row focusing rather than toggling, so the caret can still be placed', () => {
|
||||
// The toggle is scoped to 'content' on purpose: a second tap on the PROMPT
|
||||
// must still position the cursor. This is the guarantee that makes the
|
||||
// change safe to make, so it is pinned separately.
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ask', ''], 4);
|
||||
app._sendInputAsync = vi.fn();
|
||||
|
||||
setActiveElement(app.terminal.textarea);
|
||||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, true)).toBe('input');
|
||||
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();
|
||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
|
||||
Reference in New Issue
Block a user