Merge pull request #527: fix(terminal): page a hollow Codex transcript with PageUp/PageDown from the first wheel event

Conflict resolved in terminal-tile.js's @dependency header: #601's
SHELL_LOAD_BASE_QUERY/SHELL_LOAD_QUERY and #527's pageKeysForGesture and
app.tabAlerts are all listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:06:24 +02:00
commit 0e11012470
16 files changed
+1192 -96

No files matched your search

+31 -21
View File
@@ -25,13 +25,15 @@
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op.
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
* so the wheel pages the CLI's own transcript with PageUp/PageDown
* (_maybePageCliTranscript) through the primary pane's gates, plus an
* overflow-row discount for this pane's capture-before-resize load
* (_localRows), and only while the viewport is on the live screen (a
* wheel-down from those overflow rows is xterm's, and brings it home).
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
* on the alternate screen, Claude's repaint mode) leaves the xterm no
* scrollback, so the wheel pages the CLI's own transcript with
* PageUp/PageDown (_maybePageCliTranscript) through the primary pane's gates
* and its pure gesture rules (CodemanTerminalInput.pageKeysForGesture), plus
* an overflow-row discount for this pane's capture-before-resize load
* (_localRows), only while the viewport is on the live screen (a wheel-down
* from those overflow rows is xterm's, and brings it home), and never while
* THIS pane's session has a dialog open.
* - The desktop click report: a plain left-click hand-encoded as SGR while
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
* whose mouse DECSETs the server strips (_installClickListener), sent
@@ -49,7 +51,7 @@
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
@@ -204,9 +206,10 @@
// flag, app._linkHovered, belongs to its terminal alone).
this._linkHovered = false;
this._onFocusIn = null;
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
// whole page, carried to the next wheel event.
this._pageKeyPending = 0;
// Hollow-buffer paging (_maybePageCliTranscript): the gesture in progress
// (travel short of a whole page, its direction and last event time), the
// state CodemanTerminalInput.pageKeysForGesture hands back.
this._pageKeyGesture = null;
// Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0;
@@ -1154,17 +1157,20 @@
// Hollow-buffer paging, the twin of the primary pane's
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
// draws in place (opencode, on the alternate screen; Claude's repaint mode)
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the
// travel pages the CLI's own transcript with PageUp/PageDown. Every gate is
// the primary pane's own, asked for THIS pane (its terminal, its session,
// never the active one), so the CLI rules stay in terminal-ui.js and this
// file names no CLI. Returns true when the wheel was consumed here.
// draws in place (on the alternate screen, or Claude's repaint mode) leaves
// this xterm no scrollback, so a wheel scrolled nothing; instead the travel
// pages the CLI's own transcript with PageUp/PageDown. Every gate is the
// primary pane's own, asked for THIS pane (its terminal, its session, never
// the active one), so the CLI rules stay in terminal-ui.js and this file
// names no CLI. The gesture rules (first-event page, notch accumulation,
// pinch and sideways-swipe consume) are the shared pure
// CodemanTerminalInput.pageKeysForGesture, run on this tile's own state.
// Returns true when the wheel was consumed here.
_maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false;
if (!app || !input?.pageKeysForGesture || !input.wheelDeltaLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it (a shell running htop), as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
@@ -1188,8 +1194,12 @@
if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
if (!lines) return false;
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows);
this._pageKeyPending = step.pending;
// An open dialog (a pending permission prompt or question) in THIS tile's
// session, which is usually not the active one: a page key would move its
// selector. Consumed, so xterm does not scroll the rows under it either.
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys);
return true;
}
@@ -1543,7 +1553,7 @@
clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null;
this._scrollBytes = '';
this._pageKeyPending = 0;
this._pageKeyGesture = null;
this._detachSocket();
if (this._onFocusIn) {
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
+171 -33
View File
@@ -79,7 +79,7 @@
const REPLAY_ESCAPE_RE =
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g;
// PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll
// gesture for a repaint-mode CLI whose local buffer holds no scrollback
// gesture for a CLI whose local buffer holds no scrollback
// (_maybePageCliTranscript).
const KEY_PAGE_UP = '\x1b[5~';
const KEY_PAGE_DOWN = '\x1b[6~';
@@ -92,6 +92,20 @@
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
// cap: a fling must not build a backlog that keeps paging after it stops.
const PAGE_KEY_MAX_PER_BATCH = 3;
// A page-key scroll event after this much quiet, or in the opposite direction,
// starts a NEW gesture, and the first event of a gesture pages at once rather
// than waiting for half a screen of travel (a trackpad flick never got there).
const PAGE_KEY_GESTURE_GAP_MS = 150;
// ...but not for sub-row jitter (0.1 row, ~2px)...
const PAGE_KEY_MIN_START_ROWS = 0.1;
// ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px,
// 4 rows) accumulates toward half a screen as before; paging at once on it made
// slow notches, each its own gesture past the gap, page a full screen apiece.
const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2;
// A pre-paid page's debt outlives the gesture gap for this long when the next
// event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a
// Firefox 1-line notch) pay it back instead of each pre-paying a page of its own.
const PAGE_KEY_DEBT_MS = 1000;
// Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the
@@ -133,13 +147,17 @@
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// Rows of travel per page key for a terminal `rows` tall (half a screen).
function pageKeyRows(rows) {
return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
}
// Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
// `lines` to the sub-page travel already `pending`, and returns the travel
// left over plus the keys to send ('' below one page). The arithmetic of the
// primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps
// its own pending travel) pages identically.
// left over plus the keys to send ('' below one page). The plain accumulation
// step of pageKeysForGesture below.
function pageKeysForTravel(pending, lines, rows) {
const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
const perPage = pageKeyRows(rows);
const total = (pending || 0) + lines;
const pages = Math.trunc(total / perPage);
const keys = pages
@@ -148,6 +166,77 @@
return { pending: total - pages * perPage, keys };
}
// One wheel or touch event of a hollow-pane gesture → the PageUp/PageDown keys
// to send, for a terminal `rows` tall, at time `now` (ms). Pure: `state` is
// the previous call's returned state (null or {} for a fresh pane), and the
// caller keeps it, so the primary pane and every TerminalTile page with the
// same gesture rules through their own state:
// - a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel) and a mostly
// horizontal swipe (both deltas present, |deltaX| > |deltaY|) send nothing
// and leave the state alone (the caller still consumes them);
// - a NEW gesture starts on the first event, on a different touch contact
// (`ev.touchId`, set by the touch paths per touchstart and carried by that
// contact's momentum; a wheel event has none, so wheel and touch never
// share a gesture), or, for events outside any contact, after quiet:
// PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid
// page is still owed in the same direction. One contact is never split by
// time, so a slow drag (one line per 170 ms) is one gesture;
// - a new gesture's trackpad-sized opening event (at least
// PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE,
// pre-paid: the half screen it skipped is owed back by the rest of the
// gesture, so the rate stays one page per half screen. A debt left by a
// pre-paid page dies with its gesture;
// - a direction change inside a gesture (a reversal within the gap, or
// within the same contact) drops a pre-paid debt and accumulates, never
// pre-paying, so a +/-3 px trackpad wobble cannot page back and forth;
// - everything else, a wheel notch included, accumulates through
// pageKeysForTravel, carried across notches.
// Returns { state, keys } with keys '' when nothing is due.
function pageKeysForGesture(state, ev, lines, rows, now) {
const prev = state || {};
if (ev?.ctrlKey) return { state: prev, keys: '' };
const dx = Math.abs(ev?.deltaX || 0);
const dy = Math.abs(ev?.deltaY || 0);
if (dx && dy && dx > dy) return { state: prev, keys: '' };
const perPage = pageKeyRows(rows);
const dir = lines < 0 ? -1 : 1;
const touchId = typeof ev?.touchId === 'number' ? ev.touchId : null;
const prevTouchId = typeof prev.touchId === 'number' ? prev.touchId : null;
let pending = prev.pending || 0;
let prepaid = !!prev.prepaid;
const reversed = dir !== prev.dir;
let gestureStart;
if (typeof prev.lastAt !== 'number' || touchId !== prevTouchId) {
gestureStart = true;
} else if (touchId !== null) {
gestureStart = false; // the same contact, however long it has been
} else {
const quiet = now - prev.lastAt;
gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS);
}
if (gestureStart || reversed) {
// A pre-paid debt belongs to its gesture and its direction.
if (prepaid) pending = 0;
prepaid = false;
}
if (gestureStart) {
const size = Math.abs(lines);
if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) {
// Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign).
return {
state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId },
keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN,
};
}
}
const step = pageKeysForTravel(pending, lines, rows);
// A debt carries the sign opposite to its direction; once the travel reaches
// or crosses zero it is repaid, and what is left is plain travel (so the
// one-second debt lifetime no longer applies).
if (prepaid && step.pending * dir >= 0) prepaid = false;
return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys };
}
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
@@ -303,6 +392,11 @@
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
pageKeysForTravel,
pageKeysForGesture,
PAGE_KEY_GESTURE_GAP_MS,
PAGE_KEY_MIN_START_ROWS,
PAGE_KEY_IMMEDIATE_MAX_ROWS,
PAGE_KEY_DEBT_MS,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
@@ -1056,6 +1150,10 @@ Object.assign(CodemanApp.prototype, {
let lastTime = 0;
let scrollFrame = null;
let isTouching = false;
// One id per touch contact (touchstart), carried by its touchmoves AND its
// momentum frames, so the page-key fallback treats a whole contact as one
// gesture however slowly the finger moves (pageKeysForGesture).
let touchContactId = 0;
const scrollLoop = (timestamp) => {
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1;
@@ -1069,7 +1167,7 @@ Object.assign(CodemanApp.prototype, {
// Flick momentum keeps feeding the CLI's transcript from the last
// touch point; the 40ms coalescer batches the per-frame reports.
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
this.terminal.scrollLines(lines);
this._maybeLoadMoreHistoryOnScroll(lines);
}
@@ -1142,6 +1240,7 @@ Object.assign(CodemanApp.prototype, {
velocity = 0;
pixelAccum = 0;
isTouching = true;
touchContactId++;
didScroll = false;
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
// Classifying scans the whole viewport with translateToString, and
@@ -1246,7 +1345,7 @@ Object.assign(CodemanApp.prototype, {
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
this._logScrollRouting('local-scrollback');
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
@@ -5730,35 +5829,38 @@ Object.assign(CodemanApp.prototype, {
},
/**
* True when this session's LOCAL scrollback is structurally empty: a pane whose
* TUI repaints one full screen in place, so tmux keeps no history for it
* (`history_size≈0`) and xterm's normal buffer never grows past one screen
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
* is routed — the "wheel does nothing at all" half of the #205 retest.
* True when this session's LOCAL scrollback is structurally empty and its CLI
* can page its own transcript: xterm's normal buffer never grows past one
* screen (`baseY === 0`), so scrolling it is a no-op no matter how the gesture
* is routed (the "wheel does nothing at all" half of the #205 retest).
*
* Two shapes, measured separately:
* - `claude` in repaint mode (the original, #205 round 2), and
* Shapes measured so far:
* - `claude` in repaint mode (the original, #205 round 2): tmux reports
* `history_size≈0` and every frame overwrites the last;
* - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux
* `alternate_on=1`, `history_size=0`) and so pushes nothing into the
* terminal's scrollback at all. It pages its own transcript with the same
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
* byte-identical — so paging is the only gesture that reaches it. Without
* this the wheel was silently dead in every opencode tab.
* byte-identical — so paging is the only gesture that reaches it;
* - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which
* the full strip hides from xterm, and pages on PageUp/PageDown while SGR
* wheel reports do nothing.
*
* Every other mode is deliberately absent: shell/pi own real terminal
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour
* is unverified (docs/scrollback-fix-plan.md).
* ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
* list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
* literal here. A missing list means no mode pages.
*
* `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which
* calls this with its own session and terminal, so the mode list above stays
* here alone. `localRows` replaces `baseY` as the history row count: a tile
* calls this with its own session and terminal, so the mode rule stays here
* alone. `localRows` replaces `baseY` as the history row count: a tile
* discounts the stale rows its own load order leaves above the screen
* (TerminalTile._localRows). Every field left out means the primary pane's.
*/
_localScrollbackIsHollow(target = {}) {
const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude';
if (mode !== 'claude' && mode !== 'opencode') return false;
const pagingModes = window.__codemanTranscriptPageKeys;
if (!Array.isArray(pagingModes) || !pagingModes.includes(mode)) return false;
const buf = (target.terminal || this.terminal)?.buffer?.active;
if (!buf || buf.type === 'alternate') return false;
const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY;
@@ -5775,34 +5877,70 @@ Object.assign(CodemanApp.prototype, {
* unset (the inline renderer, or fullscreen right after a server restart), the
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
* opencode, which never fills the buffer and never accepts the wheel. Before
* one that never fills the buffer and never accepts the wheel (opencode, codex:
* both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before
* this, all of those produced a completely dead gesture; the #205 reporter
* proved the keyboard route works by paging back through intact text with Fn+Up.
* This is not wheel forwarding: it sends plain keys, and only over a hollow buffer.
*
* Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a
* session with real local scrollback is never touched. Shift is excluded on
* purpose: it is the explicit "give me local scrollback" gesture and must keep
* that meaning.
* Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
* `baseY === 0`) plus a false forwarding gate, so a session with real local
* scrollback is never touched. Shift is excluded on purpose: it is the explicit
* "give me local scrollback" gesture and must keep that meaning.
*
* The FIRST event of a trackpad-sized gesture (opening under
* PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of
* travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick
* never sent a key at all. That page is pre-paid: the travel it skipped is
* owed back by the rest of the gesture, so the rate stays one page per
* `perPage` rows. A new touch contact (`ev.touchId`), a pause outside a contact
* (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is
* still owed), a reversal after the gap or a tab switch starts a new gesture;
* one contact is never split by time, and a quick reversal never pre-pays. A
* larger opening event is a wheel notch and accumulates as before, so slow
* notches still page once per `perPage` rows.
*
* Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad
* pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|;
* the touch path locks its own axis before it gets here), and any gesture while
* the session has an open dialog (tab alert 'action', set by a pending
* permission_prompt or elicitation_dialog), so a page key never reaches its
* selector.
*
* @returns true when the gesture was consumed here (the caller must not also
* scroll locally).
*
* The gesture rules above live in the pure CodemanTerminalInput.pageKeysForGesture
* (top of this file); this method adds the gates and keeps the state.
*
* Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile
* through the same gates and the same pageKeysForTravel arithmetic; keep the
* two in step. The tile adds one gate this pane cannot need, viewport at the
* through the same gates and the same pageKeysForGesture, with its own state
* and its OWN session's dialog check; keep the two in step. The tile adds one gate this pane cannot need, viewport at the
* bottom: hollow here means baseY 0, so this viewport is always there, while a
* tile is hollow with its own discounted rows still above the screen.
*/
_maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (!this._localScrollbackIsHollow()) return false;
// Leftover travel belongs to the tab it was made on.
// An open dialog in THIS pane's session (the active one): a page key would
// move its selector. Consumed, so the buffer does not scroll under it either.
if (this.tabAlerts?.get(this.activeSessionId) === 'action') {
this._logScrollRouting('page-keys-dialog-open');
return true;
}
// Leftover travel and the gesture in progress belong to the tab they were made on.
if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId;
this._pageKeyPending = 0;
this._pageKeyGesture = null;
}
const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows);
this._pageKeyPending = step.pending;
const step = window.CodemanTerminalInput.pageKeysForGesture(
this._pageKeyGesture,
ev,
lines,
this.terminal?.rows,
performance.now()
);
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys);
this._logScrollRouting('page-keys');
return true;
+13
View File
@@ -1783,6 +1783,19 @@ export class WebServer extends EventEmitter {
'</head>',
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>`
);
// Which run modes page their own transcript on PageUp/PageDown, for the scroll
// fallback over a pane with no local history (`_maybePageCliTranscript`). Read off
// the `transcriptPageKeys` capability, never an id list here, and injected for a
// solo window too for the same reason as the gutter map: it scrolls a terminal.
// Ids only, which the registry schema bounds to kebab tokens, so JSON.stringify
// alone is enough.
const pageKeyClis = enabledClis()
.filter((entry) => entry.capabilities.transcriptPageKeys === true)
.map((entry) => entry.id);
html = html.replace(
'</head>',
() => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>`
);
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {