mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 13:09:42 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a43894f9 | ||
|
|
e20aa1d4d8 | ||
|
|
aa28ef048c | ||
|
|
67f6ed3168 | ||
|
|
2d4616f059 | ||
|
|
35f8f9d19f | ||
|
|
c992784681 | ||
|
|
3a7be356ae | ||
|
|
a6a572e635 |
@@ -1,5 +1,43 @@
|
||||
# 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
|
||||
|
||||
@@ -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.4 (must match `package.json`)
|
||||
**Version**: 1.16.5 (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.4",
|
||||
"version": "1.16.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.4",
|
||||
"version": "1.16.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.4",
|
||||
"version": "1.16.5",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3157,9 +3157,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 {
|
||||
|
||||
@@ -14759,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;
|
||||
}
|
||||
|
||||
@@ -14771,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 {
|
||||
|
||||
@@ -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
|
||||
@@ -182,6 +204,8 @@
|
||||
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;
|
||||
@@ -654,7 +678,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
let touchStartY = 0;
|
||||
let tapStartedWithTerminalFocus = false;
|
||||
let tapStartIntentCache = null;
|
||||
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
|
||||
// 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) => {
|
||||
@@ -786,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();
|
||||
|
||||
@@ -3491,6 +3519,73 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -3595,6 +3690,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
@@ -890,7 +1006,7 @@ describe('Virtual Keyboard', () => {
|
||||
expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/);
|
||||
});
|
||||
|
||||
it('keeps the hidden keyboard input focused after an inert Claude transcript tap', async () => {
|
||||
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';
|
||||
@@ -941,8 +1057,12 @@ describe('Virtual Keyboard', () => {
|
||||
|
||||
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).toContain('xterm-helper-textarea');
|
||||
expect(activeClass).not.toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => {
|
||||
|
||||
@@ -199,6 +199,43 @@ describe('terminal touch tap mouse guard', () => {
|
||||
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