mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,61 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 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
|
## 1.16.3
|
||||||
|
|
||||||
### Patch Changes
|
### 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.
|
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.5 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## 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.
|
**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.
|
**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.
|
⚠️ **`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",
|
"name": "aicodeman",
|
||||||
"version": "1.16.3",
|
"version": "1.16.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.16.3",
|
"version": "1.16.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.16.3",
|
"version": "1.16.5",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3157,9 +3157,36 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
|||||||
border-radius: 10px;
|
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 {
|
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save {
|
||||||
display: inline-flex;
|
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 {
|
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-foot {
|
||||||
|
|||||||
@@ -490,26 +490,55 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||||
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
// Shared helper, not a local regex: the copy that used to live here
|
// 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
|
// matched `/home/<user>/` only, so on macOS (`/Users/<user>/`) nothing was
|
||||||
// unabbreviated `/Users/<user>/…` prefix and ellipsized away the tail
|
// stripped and every row spent its first ~19 characters on an identical
|
||||||
// that identifies it (#273).
|
// prefix — with the tail ellipsized, all rows rendered as
|
||||||
|
// `/Users/jordanryan/co…` and became indistinguishable (#273).
|
||||||
const shortDir = this._shortenHomePath(s.workingDir);
|
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');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'run-mode-option';
|
btn.className = 'run-mode-option run-mode-hist-row';
|
||||||
btn.title = s.workingDir;
|
btn.title = s.workingDir;
|
||||||
btn.dataset.sessionId = s.sessionId;
|
btn.dataset.sessionId = s.sessionId;
|
||||||
btn.dataset.workingDir = s.workingDir;
|
btn.dataset.workingDir = s.workingDir;
|
||||||
|
|
||||||
const dirSpan = document.createElement('span');
|
const nameSpan = document.createElement('span');
|
||||||
dirSpan.className = 'hist-dir';
|
nameSpan.className = 'hist-name';
|
||||||
dirSpan.textContent = shortDir;
|
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');
|
const metaSpan = document.createElement('span');
|
||||||
metaSpan.className = 'hist-meta';
|
metaSpan.className = 'hist-meta';
|
||||||
metaSpan.textContent = timeStr;
|
metaSpan.textContent = timeStr;
|
||||||
|
parts.push(metaSpan);
|
||||||
|
|
||||||
btn.append(dirSpan, metaSpan);
|
btn.append(...parts);
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
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;
|
max-width: 250px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
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 {
|
.run-mode-menu.active {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4524,6 +4546,37 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
simply pinned the menu at its max-width. */
|
simply pinned the menu at its max-width. */
|
||||||
.run-mode-history .run-mode-option {
|
.run-mode-history .run-mode-option {
|
||||||
max-width: 100%;
|
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 {
|
.run-mode-option .hist-dir {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -4534,6 +4587,11 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.run-mode-option .hist-meta {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
@@ -14701,11 +14759,14 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
|||||||
flex-shrink: 0;
|
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 {
|
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 3px;
|
||||||
margin-left: auto;
|
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. */
|
instead; on desktop the footer owns Save, so this stays hidden. */
|
||||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save {
|
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save {
|
||||||
display: none;
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 30px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
padding: 8px 15px;
|
padding: 0 14px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: 9px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
|
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
|
||||||
color: var(--accent-ink);
|
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 {
|
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-body {
|
||||||
|
|||||||
+372
-35
@@ -32,6 +32,28 @@
|
|||||||
// short window, only the app's synthetic tap-to-position mouse event should
|
// short window, only the app's synthetic tap-to-position mouse event should
|
||||||
// reach xterm.
|
// reach xterm.
|
||||||
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
|
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
|
// Escape sequences occupy no terminal cells, so they must come out before a
|
||||||
// captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC,
|
// captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC,
|
||||||
// CSI, charset designators and the short escapes tmux emits; deliberately
|
// 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
|
// 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.
|
// cap: a fling must not build a backlog that keeps paging after it stops.
|
||||||
const PAGE_KEY_MAX_PER_BATCH = 3;
|
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
|
// 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
|
// 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
|
// H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional
|
||||||
@@ -180,6 +203,9 @@
|
|||||||
KEY_PAGE_DOWN,
|
KEY_PAGE_DOWN,
|
||||||
PAGE_KEY_SCREEN_FRACTION,
|
PAGE_KEY_SCREEN_FRACTION,
|
||||||
PAGE_KEY_MAX_PER_BATCH,
|
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.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||||
@@ -650,7 +676,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||||
let touchStartY = 0;
|
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(
|
container.addEventListener(
|
||||||
'touchstart',
|
'touchstart',
|
||||||
(ev) => {
|
(ev) => {
|
||||||
@@ -662,6 +692,28 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
pixelAccum = 0;
|
pixelAccum = 0;
|
||||||
isTouching = true;
|
isTouching = true;
|
||||||
didScroll = false;
|
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;
|
lastTime = 0;
|
||||||
if (scrollFrame) {
|
if (scrollFrame) {
|
||||||
cancelAnimationFrame(scrollFrame);
|
cancelAnimationFrame(scrollFrame);
|
||||||
@@ -669,7 +721,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ passive: true }
|
{ passive: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
container.addEventListener(
|
container.addEventListener(
|
||||||
@@ -721,44 +773,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||||
}
|
}
|
||||||
if (!didScroll && this.terminal) {
|
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 touch = ev.changedTouches && ev.changedTouches[0];
|
||||||
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
|
||||||
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
|
||||||
if (touch) {
|
if (touch) {
|
||||||
this._suppressTrustedTapMouseEvents();
|
this._suppressTrustedTapMouseEvents();
|
||||||
}
|
const cached =
|
||||||
if (touch && mouseTrackingOn) {
|
tapStartIntentCache &&
|
||||||
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
tapStartIntentCache.x === touch.clientX &&
|
||||||
} else if (touch && this._sessionUsesServerMouseStrip()) {
|
tapStartIntentCache.y === touch.clientY
|
||||||
// The server strips mouse-tracking DECSETs from claude/codex/gemini
|
? tapStartIntentCache.intent
|
||||||
// output (isAltScreenStripMode, session.ts) so the wheel keeps
|
: null;
|
||||||
// scrolling scrollback — which leaves THIS xterm permanently at
|
this._handleMobileTerminalTap(touch, tapStartedWithTerminalFocus, cached);
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tapStartedWithTerminalFocus = false;
|
||||||
},
|
},
|
||||||
{ passive: true }
|
{ passive: true }
|
||||||
);
|
);
|
||||||
@@ -769,6 +796,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
isTouching = false;
|
isTouching = false;
|
||||||
velocity = 0;
|
velocity = 0;
|
||||||
pixelAccum = 0;
|
pixelAccum = 0;
|
||||||
|
tapStartedWithTerminalFocus = false;
|
||||||
},
|
},
|
||||||
{ passive: true }
|
{ passive: true }
|
||||||
);
|
);
|
||||||
@@ -784,6 +812,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Hand-encode the SGR report for plain left-clicks on those sessions.
|
// Hand-encode the SGR report for plain left-clicks on those sessions.
|
||||||
container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev));
|
container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev));
|
||||||
|
|
||||||
|
this._installMobileKeyboardDismiss();
|
||||||
|
|
||||||
// Welcome message
|
// Welcome message
|
||||||
this.showWelcome();
|
this.showWelcome();
|
||||||
|
|
||||||
@@ -3373,6 +3403,313 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
} catch {}
|
} 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.
|
||||||
|
|
||||||
|
const hasMenuPrompt = lines.some((line) => /^\s*[❯›]\s+\d+[.)]\s/.test(line));
|
||||||
|
const hasMenuChoice = lines.some((line) => /^\s+\d+[.)]\s/.test(line));
|
||||||
|
return hasMenuPrompt && hasMenuChoice;
|
||||||
|
},
|
||||||
|
|
||||||
|
_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
|
// Synthetic tap → mouse report
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -624,6 +624,122 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0);
|
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 () => {
|
it('routes CJK textarea typing through local echo on Enter', async () => {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__sentInputs = [];
|
window.__sentInputs = [];
|
||||||
@@ -845,6 +961,287 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(state.sentInputs).toEqual([]);
|
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 () => {
|
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
|
||||||
const calls = await page.evaluate(async () => {
|
const calls = await page.evaluate(async () => {
|
||||||
app.activeSessionId = 'mobile-touch-scroll-test';
|
app.activeSessionId = 'mobile-touch-scroll-test';
|
||||||
|
|||||||
@@ -6,8 +6,17 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
function loadTerminalUiHarness() {
|
function loadTerminalUiHarness() {
|
||||||
const CodemanApp = function CodemanApp(this: any) {};
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
|
let keyboardVisible = false;
|
||||||
|
let activeElement: unknown = null;
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
window: {},
|
window: {},
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
get activeElement() {
|
||||||
|
return activeElement;
|
||||||
|
},
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
CodemanApp,
|
CodemanApp,
|
||||||
console: { warn: vi.fn(), log: vi.fn() },
|
console: { warn: vi.fn(), log: vi.fn() },
|
||||||
_crashDiag: { log: vi.fn() },
|
_crashDiag: { log: vi.fn() },
|
||||||
@@ -25,6 +34,11 @@ function loadTerminalUiHarness() {
|
|||||||
MobileDetection: {
|
MobileDetection: {
|
||||||
isTouchDevice: () => true,
|
isTouchDevice: () => true,
|
||||||
},
|
},
|
||||||
|
KeyboardHandler: {
|
||||||
|
get keyboardVisible() {
|
||||||
|
return keyboardVisible;
|
||||||
|
},
|
||||||
|
},
|
||||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
@@ -38,6 +52,12 @@ function loadTerminalUiHarness() {
|
|||||||
setNow: (value: number) => {
|
setNow: (value: number) => {
|
||||||
now = value;
|
now = value;
|
||||||
},
|
},
|
||||||
|
setKeyboardVisible: (visible: boolean) => {
|
||||||
|
keyboardVisible = visible;
|
||||||
|
},
|
||||||
|
setActiveElement: (element: unknown) => {
|
||||||
|
activeElement = element;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +75,167 @@ 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', () => {
|
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('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', () => {
|
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
const { element, dispatch } = createElementHarness();
|
const { element, dispatch } = createElementHarness();
|
||||||
|
|||||||
Reference in New Issue
Block a user