mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
A relative path printed in the terminal was resolved against the session's
workingDir, which is fixed at create time and never follows a cd. In a shell
that moved into a subdirectory or a sibling repo, the link opened the wrong
file or reported "File not found".
The pane-exit watcher's batched list-panes read already carries
#{pane_current_path}. derivePaneCurrentPaths() turns those rows into one
directory per single-pane session, TmuxManager.getPaneCurrentPath() serves it
from memory, and the server applies it on the existing paneExitsUpdated tick
as SessionState.cwd, broadcasting only on a change. No new tmux call, no new
SSE event. A failed read keeps the previous answers.
Session.setCwd() uses the paneExit scoping rule: remote and Docker sessions
never get a cwd (their local pane runs the ssh or docker exec client), nor do
direct-PTY or rediscovered sessions. The value is runtime-only.
In the browser, relativeResolutionBase() picks the live cwd when known and
workingDir otherwise. It only changes the base a relative path joins: the
result is routed as before, so a target outside the workspace still goes
through the attachment routes, and file-content/file-raw are unchanged.
4277 lines
200 KiB
JavaScript
4277 lines
200 KiB
JavaScript
/**
|
||
* @fileoverview Shared constants, utility functions, and SSE event type registry for all frontend modules.
|
||
*
|
||
* This is the first script loaded in index.html. Every other frontend module depends on the
|
||
* globals defined here: timing constants, Z-index layers, respawn
|
||
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml,
|
||
* getEventCoords, scheduleBackground, urlBase64ToUint8Array).
|
||
*
|
||
* @globals {function} urlBase64ToUint8Array - VAPID key conversion for Web Push
|
||
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
|
||
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
|
||
* @globals {function} escapeHtml - XSS-safe HTML escaping
|
||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (157 event types; must match backend src/web/sse-events.ts)
|
||
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
|
||
*
|
||
* @dependency None (first in load order)
|
||
* @loadorder 1 of 15 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js
|
||
* → keyboard-accessory.js → input-cjk.js → app.js → terminal-ui.js → respawn-ui.js
|
||
* → ralph-panel.js → settings-ui.js → panels-ui.js → session-ui.js → ralph-wizard.js
|
||
* → api-client.js → subagent-windows.js
|
||
*/
|
||
|
||
// Codeman — Shared constants and utility functions for frontend modules
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Reverse-proxy base path
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// When Codeman is served behind a reverse proxy under a sub-path (e.g. /codeman/),
|
||
// the server injects `window.__CODEMAN_BASE__` (normalized: '' for root, or '/foo').
|
||
// The `<base href>` tag in index.html already rewrites the RELATIVE asset refs, but
|
||
// every URL the frontend builds at RUNTIME is root-absolute (`/api/...`, `/ws/...`)
|
||
// and root-absolute URLs ignore `<base>` — so those must be prefixed here instead.
|
||
// Rather than touch ~190 call sites, all runtime URL construction routes through this
|
||
// ONE choke point: `CodemanBase.url()` is the route builder, and a thin wrapper over
|
||
// `fetch` applies it transparently. The handful of EventSource/WebSocket sites call
|
||
// `CodemanBase.url()` / `CodemanBase.base` explicitly. No-op when mounted at root.
|
||
const CodemanBase = (function () {
|
||
// `window` is absent in some unit-test vm contexts that load this module in
|
||
// isolation; guard so the module still evaluates (base degrades to root).
|
||
const _win = typeof window !== 'undefined' ? window : undefined;
|
||
const base = String((_win && _win.__CODEMAN_BASE__) || '').replace(/\/+$/, '');
|
||
/**
|
||
* Prefix a root-absolute application path with the mount base. Leaves untouched:
|
||
* relative paths and fragments/queries (resolved against `<base>`), protocol-relative
|
||
* (`//host`) and absolute URLs, and paths already carrying the prefix.
|
||
*/
|
||
function url(path) {
|
||
if (!base) return path;
|
||
if (typeof path !== 'string' || path.length === 0) return path;
|
||
if (path[0] !== '/') return path; // relative / fragment / query
|
||
if (path[1] === '/') return path; // protocol-relative
|
||
if (path === base || path.startsWith(base + '/') || path.startsWith(base + '?')) return path;
|
||
return base + path;
|
||
}
|
||
return { base, url };
|
||
})();
|
||
if (typeof window !== 'undefined') window.CodemanBase = CodemanBase;
|
||
|
||
// Transparently prefix root-absolute app paths on every fetch, so the many
|
||
// `/api/...` string literals across the frontend need no per-call edit.
|
||
if (typeof window !== 'undefined' && CodemanBase.base && typeof window.fetch === 'function') {
|
||
const _origFetch = window.fetch.bind(window);
|
||
window.fetch = function (input, init) {
|
||
if (typeof input === 'string') return _origFetch(CodemanBase.url(input), init);
|
||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||
try {
|
||
const u = new URL(input.url);
|
||
if (u.origin === location.origin) {
|
||
const prefixed = CodemanBase.url(u.pathname);
|
||
if (prefixed !== u.pathname) {
|
||
return _origFetch(new Request(u.origin + prefixed + u.search + u.hash, input), init);
|
||
}
|
||
}
|
||
} catch (_e) {
|
||
/* not a parseable URL — fall through */
|
||
}
|
||
}
|
||
return _origFetch(input, init);
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Web Push Utilities
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** Convert a base64-encoded VAPID key to Uint8Array for pushManager.subscribe() */
|
||
function urlBase64ToUint8Array(base64String) {
|
||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||
const rawData = atob(base64);
|
||
const outputArray = new Uint8Array(rawData.length);
|
||
for (let i = 0; i < rawData.length; ++i) {
|
||
outputArray[i] = rawData.charCodeAt(i);
|
||
}
|
||
return outputArray;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Constants
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// Default terminal scrollback (can be changed via settings)
|
||
const DEFAULT_SCROLLBACK = 50000;
|
||
|
||
// Timing constants
|
||
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
|
||
const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping window
|
||
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
|
||
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
|
||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||
const DEFAULT_TOAST_DURATION_MS = 3000; // How long a corner toast stays by default
|
||
const MIN_NOTIFICATION_DURATION_MS = 1000; // Shortest configurable toast / browser-notification time
|
||
const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / browser-notification time
|
||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||
// A shell's automatic loads (tab select, drop recovery, a tile's load) read
|
||
// tmux's RENDERED history, never the raw byte recording: that recording holds
|
||
// full-screen repaints (an ssh'd tmux, less, a TUI) drawn for the pane size of
|
||
// the moment, and replayed into a terminal of another height each repaint
|
||
// scrolls its top row into scrollback, so one line shows up dozens of times.
|
||
// `lines=` bounds the synchronous capture-pane (~8 ms per 1000 lines measured,
|
||
// so ~0.1 s here); the server reports a cut as 'tail', and scroll-to-top pulls
|
||
// the older lines as before. `alt=recording`: a pane in the alternate screen (a
|
||
// nested tmux, as in remote SSH and Docker cases, but also vim or less while
|
||
// open) gains no tmux history, so the server loads it the old way, visible
|
||
// frame over the tailed byte recording.
|
||
// A grid tile appends its own `lines=` to SHELL_LOAD_BASE_QUERY.
|
||
const SHELL_LOAD_HISTORY_LINES = 10000;
|
||
const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
|
||
const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=${SHELL_LOAD_HISTORY_LINES}`;
|
||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
|
||
|
||
// Z-index base values for layered floating windows
|
||
const ZINDEX_SUBAGENT_BASE = 1000;
|
||
const ZINDEX_PLAN_SUBAGENT_BASE = 1100;
|
||
const ZINDEX_LOG_VIEWER_BASE = 2000;
|
||
const ZINDEX_IMAGE_POPUP_BASE = 3000;
|
||
|
||
// Subagent/floating window layout
|
||
const WINDOW_INITIAL_TOP_PX = 120;
|
||
const WINDOW_CASCADE_OFFSET_PX = 30;
|
||
const WINDOW_MIN_WIDTH_PX = 200;
|
||
const WINDOW_MIN_HEIGHT_PX = 200;
|
||
const WINDOW_DEFAULT_WIDTH_PX = 300;
|
||
|
||
// WebGL renderer auto-fallback thresholds.
|
||
// _installWebGLLongTaskGuard() observes longtask entries and disables WebGL
|
||
// after LONGTASK_COUNT stalls of >= LONGTASK_MS within WINDOW_MS. GRACE_MS
|
||
// suppresses the noisy initial-load stalls. STICKY_EXPIRY_MS is how long
|
||
// localStorage's webgl-disabled marker survives before we retry WebGL on a
|
||
// fresh load (driver/Chrome may have been updated).
|
||
const WEBGL_FALLBACK = {
|
||
LONGTASK_MS: 200,
|
||
LONGTASK_COUNT: 3,
|
||
WINDOW_MS: 30000,
|
||
GRACE_MS: 5000,
|
||
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
|
||
};
|
||
|
||
/**
|
||
* Pure rolling-window trip evaluator for the WebGL longtask guard.
|
||
* Mutates `recent` in place (prunes entries older than `now - WINDOW_MS`)
|
||
* and appends each new duration's startTime that meets the threshold.
|
||
* Returns true when the count inside the window reaches `LONGTASK_COUNT`.
|
||
*
|
||
* Exposed on `window` for unit testing — the production guard in app.js
|
||
* inlines this same logic in its PerformanceObserver callback. Splitting it
|
||
* out keeps the threshold math testable without a real PerformanceObserver.
|
||
*
|
||
* @param {number[]} recent - mutable array of startTimes inside the window
|
||
* @param {{startTime: number, duration: number}[]} entries - new longtask entries
|
||
* @param {number} now - performance.now() at evaluation time
|
||
* @param {typeof WEBGL_FALLBACK} [config=WEBGL_FALLBACK] - thresholds
|
||
* @returns {boolean} true if the rolling window has reached the trip count
|
||
*/
|
||
function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK) {
|
||
for (const entry of entries) {
|
||
if (entry.duration >= config.LONGTASK_MS) recent.push(entry.startTime);
|
||
}
|
||
while (recent.length && now - recent[0] > config.WINDOW_MS) recent.shift();
|
||
return recent.length >= config.LONGTASK_COUNT;
|
||
}
|
||
|
||
/**
|
||
* Pure decision for whether to skip the WebGL renderer at terminal init, and
|
||
* whether to clear the auto-fallback sticky marker. Keeps the interaction
|
||
* between device type, URL params, the sticky marker, and the user's settings
|
||
* toggle in one testable place (terminal-ui.js calls this).
|
||
*
|
||
* Precedence (desktop only — mobile always skips):
|
||
* 1. user toggle OFF -> skip (one-shot opt-out, sticky untouched)
|
||
* 2. ?nowebgl -> skip (one-shot opt-out, sticky untouched)
|
||
* 3. ?webgl=force -> enable + clear stale sticky marker
|
||
* 4. toggle ON / untouched -> respect the auto-fallback sticky marker
|
||
*
|
||
* A stored `true` is treated like the untouched default here: the checkbox
|
||
* ships checked on desktop, so any unrelated settings save stores `true` —
|
||
* letting it clear the marker would permanently defeat the GPU-stall
|
||
* auto-fallback safety net. The marker is only retired by ?webgl=force or by
|
||
* a real OFF->ON toggle flip, which saveAppSettings() detects at save time.
|
||
*
|
||
* @param {{deviceType?: string, noWebglParam?: boolean, forceParam?: boolean,
|
||
* stickyDisabled?: boolean, userPrefEnabled?: (boolean|undefined)}} [input]
|
||
* @returns {{skip: boolean, clearSticky: boolean}}
|
||
*/
|
||
function shouldSkipWebGL(input = {}) {
|
||
if (input.deviceType !== 'desktop') return { skip: true, clearSticky: false };
|
||
if (input.userPrefEnabled === false) return { skip: true, clearSticky: false };
|
||
if (input.noWebglParam) return { skip: true, clearSticky: false };
|
||
if (input.forceParam) return { skip: false, clearSticky: true };
|
||
return { skip: !!input.stickyDisabled, clearSticky: false };
|
||
}
|
||
|
||
// Expose for tests. `const` declarations at the top of a non-module script
|
||
// are global lexical bindings but not `window` properties, so explicit
|
||
// assignment is the test-visible API surface.
|
||
// Desktop tab-overflow policy: auto-wrap the session tabs to a second row when
|
||
// they overflow one row (and the user hasn't pinned the manual two-row layout).
|
||
function shouldAutoWrapTabs(input) {
|
||
if (!input || input.deviceType !== 'desktop') return false;
|
||
if (input.manualTwoRows) return false;
|
||
if ((input.tabCount || 0) < 2) return false;
|
||
// A box in the strip already wraps inside itself (a case cluster wider than the
|
||
// whole strip): the strip has rows although nothing overflows.
|
||
if (input.innerWrap) return true;
|
||
|
||
const scrollWidth = Number(input.scrollWidth) || 0;
|
||
const clientWidth = Number(input.clientWidth) || 0;
|
||
return scrollWidth > clientWidth + 1;
|
||
}
|
||
|
||
function resolveTabOrientation(input) {
|
||
if (!input || input.setting !== 'vertical') return 'horizontal';
|
||
if (input.deviceType === 'mobile') return 'horizontal';
|
||
return 'vertical';
|
||
}
|
||
|
||
const TAB_RAIL_MIN_WIDTH = 208;
|
||
const TAB_RAIL_DEFAULT_WIDTH = 256;
|
||
/** Detailed rows carry a third line, and it ellipsizes at 256px — see the
|
||
rich sidebar's own 300px column. 320px is the existing Wide preset. */
|
||
const TAB_RAIL_RICH_DEFAULT_WIDTH = 320;
|
||
const TAB_RAIL_MAX_WIDTH = 360;
|
||
|
||
function resolveTabRailWidth(input = {}) {
|
||
const viewportWidth = Number(input.viewportWidth);
|
||
const mainWidth = Number(input.mainWidth);
|
||
const minTerminalWidth = Number(input.minTerminalWidth);
|
||
const limits = [TAB_RAIL_MAX_WIDTH];
|
||
if (Number.isFinite(viewportWidth) && viewportWidth > 0) limits.push(Math.floor(viewportWidth * 0.4));
|
||
if (Number.isFinite(mainWidth) && mainWidth > 0 && Number.isFinite(minTerminalWidth) && minTerminalWidth > 0) {
|
||
limits.push(Math.floor(mainWidth - minTerminalWidth));
|
||
}
|
||
const effectiveMax = Math.max(TAB_RAIL_MIN_WIDTH, Math.min(...limits));
|
||
const requested = Number(input.width);
|
||
const width = Number.isFinite(requested) ? requested : TAB_RAIL_DEFAULT_WIDTH;
|
||
return Math.round(Math.min(effectiveMax, Math.max(TAB_RAIL_MIN_WIDTH, width)));
|
||
}
|
||
|
||
function resolveTabRailKeyboardWidth(input = {}) {
|
||
let width;
|
||
if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH;
|
||
else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH;
|
||
// Enter resets to the caller's effective default (the rich rail's is the
|
||
// Wide preset, not 256 — see _defaultTabRailWidth); absent, the base default.
|
||
else if (input.key === 'Enter') width = Number(input.defaultWidth) || TAB_RAIL_DEFAULT_WIDTH;
|
||
else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') {
|
||
const direction = input.key === 'ArrowLeft' ? -1 : 1;
|
||
width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8);
|
||
} else return null;
|
||
return resolveTabRailWidth({ ...input, width });
|
||
}
|
||
|
||
// Sliver of the neighbouring tab left visible when the strip scrolls a tab into
|
||
// view. Landing a tab flush against the edge reads as "this is the last one";
|
||
// the gap is what tells the user there is more strip to swipe to.
|
||
const TAB_SCROLL_REVEAL_PX = 16;
|
||
|
||
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
|
||
// the strip horizontally (desktop wraps to a second row instead and never
|
||
// scrolls), so the active tab can sit entirely outside the visible slice with
|
||
// no way back except a swipe the user may not know is possible.
|
||
//
|
||
// Returns the scrollLeft that puts the tab inside the window, clamped to the
|
||
// scrollable range, and returns the CURRENT scrollLeft when the tab is already
|
||
// visible: callers compare and skip the write, so an already-correct strip is
|
||
// never nudged. Pure: the caller measures, this decides.
|
||
function computeTabScrollLeft(input) {
|
||
const scrollWidth = Number(input?.scrollWidth) || 0;
|
||
const clientWidth = Number(input?.clientWidth) || 0;
|
||
const maxScroll = Math.max(0, scrollWidth - clientWidth);
|
||
if (maxScroll === 0 || clientWidth <= 0) return 0;
|
||
|
||
const pad = input?.padding == null ? TAB_SCROLL_REVEAL_PX : Number(input.padding) || 0;
|
||
const tabLeft = Number(input?.tabLeft) || 0;
|
||
const tabWidth = Number(input?.tabWidth) || 0;
|
||
const tabRight = tabLeft + tabWidth;
|
||
const viewLeft = Math.min(Math.max(Number(input?.scrollLeft) || 0, 0), maxScroll);
|
||
const viewRight = viewLeft + clientWidth;
|
||
|
||
let target = viewLeft;
|
||
if (tabWidth + pad >= clientWidth) {
|
||
// Tab is as wide as the window (long session name on a narrow phone):
|
||
// there is no position that shows all of it plus padding, so align its
|
||
// start, since the name matters more than the trailing badges.
|
||
target = tabLeft;
|
||
} else if (tabLeft - pad < viewLeft) {
|
||
target = tabLeft - pad;
|
||
} else if (tabRight + pad > viewRight) {
|
||
target = tabRight + pad - clientWidth;
|
||
}
|
||
return Math.min(Math.max(Math.round(target), 0), maxScroll);
|
||
}
|
||
|
||
// Session lineage lines: geometry for the lines joining a tab to the tabs it
|
||
// spawned (a worker started through the codeman agent skill, which passes its own
|
||
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
||
//
|
||
// ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
|
||
// (session-lineage.js). Every route starts at the PARENT and ends at one child, so
|
||
// a parent's routes share their first stretch exactly: overlaid, they read as one
|
||
// trunk with a branch per child, and a dashed (working) route stays in phase with
|
||
// its siblings along the shared part.
|
||
//
|
||
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
|
||
// child hanging below the strip, which in a wrapped strip crossed every lower
|
||
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
|
||
// row 3 with ten children, "too confusing"). A route now moves horizontally only
|
||
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
|
||
// the gap right under it) or along the SPINE, a channel left of every row that
|
||
// joins the gaps of different rows. styles.css reserves that room
|
||
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
|
||
// row's gap, and the spine channel on the left of a wrapped strip).
|
||
//
|
||
// The channel is at the strip's left edge, except where a tab arrangement puts
|
||
// something there: grouped by state, the edge holds the label column and the
|
||
// channel opens between the labels and the tabs. session-lineage.js measures it
|
||
// and passes its left edge as `spineLeft`; without one it is the strip's edge.
|
||
//
|
||
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
|
||
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
|
||
// taller), or siblings in one row would hang their bus at different heights.
|
||
//
|
||
// computeLineageTree() returns null when the parent cannot be drawn (missing or
|
||
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
|
||
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
|
||
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
|
||
// would point at a tab that is not there.
|
||
//
|
||
// A child is also left out when its route has nowhere to run: rows whose spans
|
||
// overlap are one row (no gap between them), a row with no gap under it routes
|
||
// nothing, and a route that would still cross a tab is dropped whole. So no tab
|
||
// arrangement can put a line through a tab; a layout without the reserved room
|
||
// loses lines instead.
|
||
const LINEAGE_CORNER_RADIUS_PX = 10;
|
||
// Families drawn together take separate lanes: gap lines this far apart, spines
|
||
// LINEAGE_SPINE_STEP_PX apart.
|
||
const LINEAGE_LANE_STEP_PX = 3.5;
|
||
// Every family is drawn at once, and a 12px row gap only fits this many lanes at
|
||
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
|
||
const LINEAGE_MAX_LANES = 3;
|
||
const LINEAGE_SPINE_INSET_PX = 6;
|
||
const LINEAGE_SPINE_STEP_PX = 4;
|
||
// The last row has no row below it; with no strip rect to measure, its gap is
|
||
// taken to be this deep.
|
||
const LINEAGE_LAST_GAP_PX = 12;
|
||
// Narrower than this, the space between two rows is not a gap a route can run in.
|
||
const LINEAGE_MIN_GAP_PX = 2;
|
||
const LINEAGE_ROW_TOLERANCE_PX = 6;
|
||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||
// How far the vertical rail's track sits in from the rail's left edge. It has to
|
||
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
|
||
// to the window frame. The rail reserves the channel itself
|
||
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
|
||
// still clamped to stay left of every endpoint.
|
||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
||
const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
|
||
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
||
// (session-lineage.js). Every line leaving one tab shares its colour however many
|
||
// workers it spawns; a child that spawns in turn gets its own for the lines below it.
|
||
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
|
||
// which every skin block tunes for its own background, so a lone family keeps the
|
||
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
|
||
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
|
||
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||
|
||
/** A rect normalized to numbers with its edges and center, or null if unusable. */
|
||
function lineageRect(rect) {
|
||
if (!rect) return null;
|
||
const left = Number(rect.left);
|
||
const top = Number(rect.top);
|
||
const width = Number(rect.width);
|
||
const height = Number(rect.height);
|
||
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
|
||
return {
|
||
left,
|
||
top,
|
||
width,
|
||
height,
|
||
right: left + width,
|
||
bottom: top + height,
|
||
cx: left + width / 2,
|
||
cy: top + height / 2,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
|
||
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
|
||
* gap for everyone in it.
|
||
*
|
||
* ⚠ Rows never overlap. Tabs whose spans overlap are in one row however far apart
|
||
* their tops are: case clusters stack and centre tabs inside their boxes, and two
|
||
* overlapping "rows" put the gap of one in the middle of the other's tabs.
|
||
*/
|
||
function computeLineageRows(rects) {
|
||
const sorted = [];
|
||
for (const raw of rects || []) {
|
||
const r = lineageRect(raw);
|
||
if (r) sorted.push(r);
|
||
}
|
||
sorted.sort((a, b) => a.top - b.top);
|
||
const rows = [];
|
||
for (const r of sorted) {
|
||
// Sorted by top, so only the last row can take this rect, and merging into it
|
||
// keeps every earlier row clear of it.
|
||
const row = rows[rows.length - 1];
|
||
if (row && (r.top < row.bottom || r.top - row.top <= LINEAGE_ROW_TOLERANCE_PX)) {
|
||
row.bottom = Math.max(row.bottom, r.bottom);
|
||
} else {
|
||
rows.push({ top: r.top, bottom: r.bottom });
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
/** Does an axis-aligned segment pass through the inside of a rect? Touching an edge is fine. */
|
||
function lineageSegmentCrosses([x1, y1], [x2, y2], r) {
|
||
const eps = 0.5;
|
||
if (Math.max(x1, x2) <= r.left + eps || Math.min(x1, x2) >= r.right - eps) return false;
|
||
return Math.max(y1, y2) > r.top + eps && Math.min(y1, y2) < r.bottom - eps;
|
||
}
|
||
|
||
/**
|
||
* An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
|
||
* (never more than half of either segment, so short stems stay short). Repeated and
|
||
* collinear points are dropped first, so a degenerate corner draws nothing odd.
|
||
*/
|
||
function lineagePolylinePath(points, radius) {
|
||
const pts = [];
|
||
for (const p of points) {
|
||
const prev = pts[pts.length - 1];
|
||
if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
|
||
const before = pts[pts.length - 2];
|
||
if (before && prev) {
|
||
const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
|
||
if (Math.abs(cross) < 0.01) pts.pop();
|
||
}
|
||
pts.push(p);
|
||
}
|
||
if (pts.length < 2) return null;
|
||
let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
|
||
for (let i = 1; i < pts.length - 1; i++) {
|
||
const [x0, y0] = pts[i - 1];
|
||
const [x1, y1] = pts[i];
|
||
const [x2, y2] = pts[i + 1];
|
||
const lenIn = Math.hypot(x1 - x0, y1 - y0);
|
||
const lenOut = Math.hypot(x2 - x1, y2 - y1);
|
||
const r = Math.min(radius, lenIn / 2, lenOut / 2);
|
||
if (!(r > 0.5)) {
|
||
d += ` L ${r1(x1)} ${r1(y1)}`;
|
||
continue;
|
||
}
|
||
const ax = x1 - ((x1 - x0) / lenIn) * r;
|
||
const ay = y1 - ((y1 - y0) / lenIn) * r;
|
||
const bx = x1 + ((x2 - x1) / lenOut) * r;
|
||
const by = y1 + ((y2 - y1) / lenOut) * r;
|
||
d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
|
||
}
|
||
const last = pts[pts.length - 1];
|
||
return d + ` L ${r1(last[0])} ${r1(last[1])}`;
|
||
}
|
||
|
||
/**
|
||
* Routes from one parent tab to each of its children.
|
||
*
|
||
* input: { parent, children: [{ id, rect }], strip?, tabs?, spineLeft?,
|
||
* orientation?, lane?, laneCount?, radius? }. `tabs` is every tab rect in
|
||
* the strip (rows are derived from it); `spineLeft` is the left edge of the
|
||
* spine channel (default: the strip's left edge); `lane`/`laneCount`
|
||
* separate families drawn together.
|
||
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
|
||
*/
|
||
function computeLineageTree(input) {
|
||
const parent = lineageRect(input?.parent);
|
||
if (!parent) return null;
|
||
const strip = lineageRect(input?.strip);
|
||
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
|
||
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
|
||
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
|
||
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
|
||
const children = [];
|
||
for (const child of input?.children || []) {
|
||
const rect = lineageRect(child?.rect);
|
||
if (rect) children.push({ id: child.id, rect });
|
||
}
|
||
|
||
const tol = LINEAGE_STRIP_TOLERANCE_PX;
|
||
const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
|
||
const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
|
||
const routes = [];
|
||
|
||
if (orientation === 'vertical') {
|
||
// The rail: one track down the empty left gutter, shared by every sibling.
|
||
if (!inStripY(parent)) return null;
|
||
const visible = children.filter((c) => inStripY(c.rect));
|
||
if (visible.length === 0) return { routes };
|
||
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
|
||
const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
|
||
const trackX = Math.min(
|
||
base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
|
||
minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
|
||
);
|
||
for (const { id, rect } of visible) {
|
||
const points = [
|
||
[parent.left, parent.cy],
|
||
[trackX, parent.cy],
|
||
[trackX, rect.cy],
|
||
[rect.left, rect.cy],
|
||
];
|
||
const d = lineagePolylinePath(points, radius);
|
||
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
|
||
}
|
||
return { routes };
|
||
}
|
||
|
||
if (!inStripX(parent) || !inStripY(parent)) return null;
|
||
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
|
||
if (visible.length === 0) return { routes };
|
||
|
||
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
|
||
const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
|
||
const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
|
||
// Y of the gap under row i, this family's lane. The offset is clamped so a busy
|
||
// gap never pushes a lane into the tabs on either side of it. Null when there is
|
||
// no gap: the row is not found, or the next row starts (nearly) where it ends.
|
||
const gapUnder = (i) => {
|
||
const row = rows[i];
|
||
if (!row) return null;
|
||
const next = rows[i + 1];
|
||
let bottom;
|
||
if (next) bottom = next.top;
|
||
else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
|
||
else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
|
||
if (!(bottom - row.bottom >= LINEAGE_MIN_GAP_PX)) return null;
|
||
const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
|
||
return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
|
||
};
|
||
const pRow = rowOf(parent);
|
||
const gp = gapUnder(pRow);
|
||
if (gp === null) return { routes };
|
||
// The spine runs from one row's gap to another's, so it only ever passes BESIDE
|
||
// rows after the first, and only their tabs bound it. The first row may start
|
||
// left of the channel (grouped by state it starts after the brand and a label
|
||
// of its own width), and clamping to it would pull the spine back over the
|
||
// label column.
|
||
const lowerLefts = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)]
|
||
.filter((r) => r && rowOf(r) > 0)
|
||
.map((r) => r.left);
|
||
const minLeft = lowerLefts.length
|
||
? Math.min(...lowerLefts)
|
||
: Math.min(parent.left, ...visible.map((c) => c.rect.left));
|
||
const channelLeft = Number(input?.spineLeft);
|
||
const spineBase = strip
|
||
? Math.max(strip.left, Number.isFinite(channelLeft) ? channelLeft : strip.left)
|
||
: minLeft - LINEAGE_SPINE_INSET_PX * 2;
|
||
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
|
||
|
||
// Every tab a route must stay out of. A segment can only cross one if the rows
|
||
// above failed to describe the layout, so this is a backstop that drops the
|
||
// route whole rather than drawing it through a tab.
|
||
const obstacles = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)].filter(Boolean);
|
||
for (const { id, rect } of visible) {
|
||
const cRow = rowOf(rect);
|
||
const gc = gapUnder(cRow);
|
||
if (gc === null) continue;
|
||
const points =
|
||
cRow === pRow
|
||
? [
|
||
[parent.cx, parent.bottom],
|
||
[parent.cx, gp],
|
||
[rect.cx, gp],
|
||
[rect.cx, rect.bottom],
|
||
]
|
||
: [
|
||
[parent.cx, parent.bottom],
|
||
[parent.cx, gp],
|
||
[spineX, gp],
|
||
[spineX, gc],
|
||
[rect.cx, gc],
|
||
[rect.cx, rect.bottom],
|
||
];
|
||
// The rounded corners cut inside each turn by a few pixels at most
|
||
// (lineagePolylinePath), so the segments are what has to clear the tabs.
|
||
const blocked = points.some((p, i) => i > 0 && obstacles.some((r) => lineageSegmentCrosses(points[i - 1], p, r)));
|
||
if (blocked) continue;
|
||
const d = lineagePolylinePath(points, radius);
|
||
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
|
||
}
|
||
return { routes };
|
||
}
|
||
|
||
function roundPoints(points) {
|
||
return points.map(([x, y]) => [r1(x), r1(y)]);
|
||
}
|
||
|
||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||
function r1(n) {
|
||
return Math.round(n * 10) / 10;
|
||
}
|
||
|
||
// COD-134 — Terminal WebSocket reconnect policy.
|
||
//
|
||
// Decide what to do after a terminal WebSocket closes, given the close `code`
|
||
// and `attempt` (0-based count of consecutive reconnects already made):
|
||
// - transient closes (code < 4004: 1000/1001/1005/1006/etc.) → 'reconnect'
|
||
// with exponential backoff (0 on the first attempt; the caller adds jitter),
|
||
// 250ms → 500 → 1000 → ... capped at 10s.
|
||
// - 4004 (session not found) / 4009 (session terminated) → 'give-up': the
|
||
// session is gone, retrying only wastes connections.
|
||
// - 4008 (too many connections) and any other code >= 4004 → 'retry-fallback':
|
||
// show the HTTP fallback but keep retrying on a bounded 5s timer so the
|
||
// transport returns to WS once the transient condition clears (un-stick).
|
||
// Pure: no DOM, no side effects.
|
||
function planWsReconnect(code, attempt) {
|
||
if (code === 4004 || code === 4009) {
|
||
return { action: 'give-up', delayMs: 0 };
|
||
}
|
||
if (code >= 4004) {
|
||
return { action: 'retry-fallback', delayMs: 5000 };
|
||
}
|
||
const delayMs = attempt <= 0 ? 0 : Math.min(250 * Math.pow(2, attempt - 1), 10000);
|
||
return { action: 'reconnect', delayMs };
|
||
}
|
||
|
||
// Connection-loss UI policy.
|
||
//
|
||
// With the service worker serving the cached app shell, Codeman still *renders*
|
||
// when the server is unreachable (phone off the tailnet, VPN down, server
|
||
// stopped): a dashboard with no sessions and an 8px red dot in the header
|
||
// corner. That reads as "there are no sessions", not "you are not connected".
|
||
// This decides what the app surfaces instead:
|
||
//
|
||
// 'overlay': full-screen "can't reach Codeman". Used while the page has
|
||
// never loaded server state, where the UI behind it is empty
|
||
// anyway, so blocking it costs nothing and explains everything.
|
||
// 'banner': non-blocking bar under the header. Used once state HAS loaded,
|
||
// so the terminal scrollback stays readable while the link is down.
|
||
// 'hidden': connected, or still inside the grace window.
|
||
//
|
||
// Grace: a COM deploy restarts the server and SSE is back in ~200ms. Shouting
|
||
// on every deploy trains the user to ignore the warning, so a transport that is
|
||
// merely *not yet connected* gets CONNECTION_LOSS_GRACE_MS to recover.
|
||
// `navigator.onLine === false` skips the grace entirely: the device itself is
|
||
// saying there is no network, which is never a 200ms blip.
|
||
//
|
||
// Pure: no DOM, no timers, no side effects. `now` is passed in.
|
||
const CONNECTION_LOSS_GRACE_MS = 2500;
|
||
|
||
function computeConnectionLossUi(input) {
|
||
const {
|
||
isOnline = true,
|
||
status = 'connected',
|
||
everLoaded = false,
|
||
downSince = null,
|
||
now = 0,
|
||
nextRetryAt = null,
|
||
overlayDismissed = false,
|
||
retryPending = false,
|
||
} = input || {};
|
||
|
||
const hidden = { mode: 'hidden', kind: 'connected', title: '', detail: '', retryInSec: null };
|
||
|
||
// The browser's own offline flag outranks the transport state: no network
|
||
// means no reconnect is coming until it returns.
|
||
const hardOffline = !isOnline || status === 'offline';
|
||
if (!hardOffline) {
|
||
if (status === 'connected') return hidden;
|
||
const downMs = downSince == null ? 0 : Math.max(0, now - downSince);
|
||
if (downMs < CONNECTION_LOSS_GRACE_MS) return { ...hidden, kind: 'connecting' };
|
||
}
|
||
|
||
// Dismissing the overlay ("show cached view") demotes it to the banner for
|
||
// the rest of this outage, never back to invisible.
|
||
const mode = everLoaded || overlayDismissed ? 'banner' : 'overlay';
|
||
// A retry the user just triggered has no scheduled time; the caller renders
|
||
// an indeterminate "Retrying…" for null.
|
||
const retryInSec =
|
||
retryPending || nextRetryAt == null ? null : Math.max(0, Math.ceil((nextRetryAt - now) / 1000));
|
||
|
||
if (hardOffline) {
|
||
return {
|
||
mode,
|
||
kind: 'offline',
|
||
title: 'No network connection',
|
||
detail: 'This device is offline. Codeman is showing the last cached view.',
|
||
retryInSec,
|
||
};
|
||
}
|
||
return {
|
||
mode,
|
||
kind: 'unreachable',
|
||
title: "Can't reach the Codeman server",
|
||
detail:
|
||
'This device has a network, but the Codeman server is not answering. ' +
|
||
'If you reach Codeman over Tailscale or a VPN, check that it is connected.',
|
||
retryInSec,
|
||
};
|
||
}
|
||
|
||
// Session Options → Respawn → "Prompt cache": one sentence of the main
|
||
// conversation's cache state as Claude Code reports it on the statusline
|
||
// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
|
||
// because the row refreshes on SSE, not on a timer, and a countdown would sit
|
||
// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
|
||
// session's `statusLineExporter`: a launch without the exporter never reports, so
|
||
// it says why instead of "not reported yet" forever.
|
||
const PROMPT_CACHE_NOT_COLLECTED = {
|
||
'collection-off':
|
||
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings → Header & Panels, then start a new session; this one cannot pick it up.',
|
||
'workspace-statusline':
|
||
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
|
||
remote: 'Not collected for remote and Docker sessions.',
|
||
};
|
||
function formatPromptCacheStatus(cache, now, statusLine) {
|
||
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine];
|
||
if (!cache || typeof cache.warm !== 'boolean') {
|
||
return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.';
|
||
}
|
||
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
|
||
const stake = cache.recacheTokensIfCold;
|
||
const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null;
|
||
const parts = [];
|
||
if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
|
||
const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
|
||
parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
|
||
if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`);
|
||
} else {
|
||
parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.');
|
||
}
|
||
// A compaction or a tool-result clearing leaves no re-cache stake to show until the next
|
||
// request; naming when it happened explains the gap. Never an alert.
|
||
if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`);
|
||
if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
|
||
if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
|
||
return parts.join(' ');
|
||
}
|
||
|
||
// Session Options → Respawn → Prompt cache → Status: the keep-warm line under the cache
|
||
// readout, saying what the keep-warm service is doing (`session.keepWarm`, published by the
|
||
// server on `session:keepWarm`), compact and dot-separated. Empty when there is nothing to
|
||
// say: switched off, not published yet, or a launch without the statusLine exporter, where
|
||
// the readout's own "Not collected" sentence on the same row already says why and
|
||
// session-ui.js greys the switch. Pure: no DOM, `now` is passed in.
|
||
function formatKeepWarmStatus(status, now, statusLine) {
|
||
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return '';
|
||
if (!status || typeof status.state !== 'string') return '';
|
||
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
const keepWarmWindow = Number.isFinite(status.budgetEndsAt)
|
||
? status.budgetEndsAt > now
|
||
? `stops ${clock(status.budgetEndsAt)}`
|
||
: 'window closed'
|
||
: '';
|
||
// Keep-warm switched on or off after launch: the `idleCompaction` opt-out rides only a launch.
|
||
const idleCompaction =
|
||
status.enabled && status.idleCompactionOff === false
|
||
? 'idle compaction on until relaunch'
|
||
: !status.enabled && status.idleCompactionOff === true
|
||
? 'idle compaction off until relaunch'
|
||
: '';
|
||
let parts;
|
||
switch (status.state) {
|
||
case 'off': {
|
||
const reason = status.reason === 'off' ? '' : status.reason;
|
||
if (!reason && !idleCompaction) return '';
|
||
parts = ['off', reason, idleCompaction];
|
||
break;
|
||
}
|
||
case 'armed':
|
||
parts = [`next ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
|
||
break;
|
||
case 'waiting':
|
||
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
|
||
break;
|
||
case 'nudged':
|
||
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction];
|
||
break;
|
||
default:
|
||
parts = ['stopped', status.reason || 'nothing to do', idleCompaction];
|
||
}
|
||
return `Keep-alive: ${parts.filter(Boolean).join(' \u00b7 ')}`;
|
||
}
|
||
|
||
// SSE staleness policy: is this stream a zombie?
|
||
//
|
||
// An EventSource that stops delivering does not always error. A proxy that
|
||
// idle-closed the connection, a laptop resumed from sleep, a tailnet
|
||
// reconnect: `onerror` never fires, the header dot stays green, and every
|
||
// SSE-driven surface (tab status dots, sessions created on another device,
|
||
// renames) freezes until the user reloads. The server writes a
|
||
// `sse:heartbeat` frame every 15s, so silence longer than three of them means
|
||
// the stream is dead even though the transport still claims otherwise.
|
||
//
|
||
// Stale ONLY when the transport believes it is 'connected': the other states
|
||
// already have the reconnect/backoff machinery running, and re-firing on top
|
||
// of them would stack reconnects. That guard is also the loop breaker: a
|
||
// forced reconnect leaves 'connected' immediately, so the watchdog cannot
|
||
// fire again while one is in flight. `navigator.onLine === false` is not
|
||
// staleness either; there is nothing to reconnect to yet.
|
||
//
|
||
// Pure: no DOM, no timers, no side effects. `now` is passed in.
|
||
const SSE_STALE_TIMEOUT_MS = 45000; // three missed 15s heartbeats
|
||
|
||
// The watchdog above cannot see a connect attempt that never opens: an
|
||
// EventSource whose request gets no response headers (a resumed phone on a
|
||
// VPN, a proxy that accepted the socket and went silent) stays CONNECTING
|
||
// without firing `open` or `error`, and its status is never 'connected'.
|
||
// connectSSE() bounds each attempt by this much and then retries it through
|
||
// the normal backoff. Generous next to a healthy open (well under a second)
|
||
// so a slow link is not cut off mid-handshake.
|
||
const SSE_CONNECT_TIMEOUT_MS = 15000;
|
||
|
||
function computeSseStale(input) {
|
||
const {
|
||
lastMessageAt = null,
|
||
now = 0,
|
||
status = 'connected',
|
||
isOnline = true,
|
||
timeoutMs = SSE_STALE_TIMEOUT_MS,
|
||
} = input || {};
|
||
if (!isOnline || status !== 'connected') return false;
|
||
// No frame has ever arrived: `init` lands on connect, so this is a stream
|
||
// that has not opened yet rather than one that went quiet.
|
||
if (typeof lastMessageAt !== 'number' || !(lastMessageAt > 0)) return false;
|
||
return now - lastMessageAt >= timeoutMs;
|
||
}
|
||
|
||
// Home-screen session order: one comparator for both overviews.
|
||
//
|
||
// The phone overview (mobile-overview.js) and the desktop tab rail
|
||
// (home-sessions.js) list the same sessions, so they answer the same question
|
||
// and must answer it the same way: "which of these wants me next?".
|
||
//
|
||
// 1. Anything blocked on a human first (red question, then error, then a
|
||
// yellow idle prompt), longest-blocked at the top: a session that has been
|
||
// sitting on a permission dialog for 20 minutes is starving, one that
|
||
// raised it 5 seconds ago is not.
|
||
// 2. Then whatever is running, LONGEST-RUNNING first, since that is the turn most
|
||
// likely to be finished, or stuck, by the time you look.
|
||
// 3. Then everything quiet, MOST RECENTLY quiet first: when nothing is
|
||
// running, the session that just finished is the one you came back for,
|
||
// and the one you abandoned yesterday sinks.
|
||
//
|
||
// So the tiebreak flips direction halfway down the list, and that is the point:
|
||
// for a state something is still doing, longer = more urgent; for a state
|
||
// something has stopped in, more recent = more relevant.
|
||
//
|
||
// Pure: no DOM, no clock (every input is an epoch-ms stamp already on the
|
||
// session payload), no `this`. Unit-tested in test/session-overview-order.test.ts.
|
||
const SESSION_ACTIVITY_RANK = {
|
||
needs: 0,
|
||
error: 1,
|
||
waiting: 2,
|
||
working: 3,
|
||
idle: 4,
|
||
done: 5,
|
||
};
|
||
|
||
/** States still in progress, where the OLDEST stamp sorts first. */
|
||
const SESSION_ACTIVITY_OLDEST_FIRST = ['needs', 'error', 'waiting', 'working'];
|
||
|
||
/**
|
||
* When the row entered the state it is in.
|
||
*
|
||
* For everything quiet that is `lastActivityAt`, the last byte the pane printed:
|
||
* a Claude pane sitting at its composer prints nothing, so the end of the last
|
||
* turn is exactly when it went quiet.
|
||
*
|
||
* A WORKING pane is the opposite: it repaints about once a second, so its
|
||
* last-activity stamp is always "now" and would rank every running turn as
|
||
* freshly started. Its real start is the pane's last Enter (`lastSubmitAt`),
|
||
* persisted server-side and therefore stable across a Codeman restart. A
|
||
* working pane that has never submitted (spawned with its prompt on the command
|
||
* line, or an external CLI) falls back to last activity, which puts it at the
|
||
* short end of the running group rather than falsely at the head of it.
|
||
*/
|
||
function sessionActivityAnchor(row) {
|
||
const activeAt = Number(row && row.lastActivityAt) || 0;
|
||
if (row && row.state === 'working') return Number(row.lastSubmitAt) || activeAt;
|
||
return activeAt;
|
||
}
|
||
|
||
/**
|
||
* Sort comparator for one overview row against another.
|
||
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} a
|
||
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} b
|
||
*/
|
||
function compareSessionActivity(a, b) {
|
||
const rankA = SESSION_ACTIVITY_RANK[a.state];
|
||
const rankB = SESSION_ACTIVITY_RANK[b.state];
|
||
const rank = (rankA === undefined ? 99 : rankA) - (rankB === undefined ? 99 : rankB);
|
||
if (rank !== 0) return rank;
|
||
|
||
const atA = sessionActivityAnchor(a);
|
||
const atB = sessionActivityAnchor(b);
|
||
if (atA !== atB) {
|
||
// A row with no stamp at all gets no opinion: it sorts last either way
|
||
// rather than claiming to be the oldest (0) thing on the screen.
|
||
if (!atA) return 1;
|
||
if (!atB) return -1;
|
||
return SESSION_ACTIVITY_OLDEST_FIRST.includes(a.state) ? atA - atB : atB - atA;
|
||
}
|
||
|
||
// Equal stamps (or two unstamped rows): fall back to the user's tab order so
|
||
// the list is deterministic and cannot shuffle between renders.
|
||
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
||
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
||
return orderA - orderB;
|
||
}
|
||
|
||
/** Copy of `rows`, in overview order. Never sorts in place, so callers keep their array. */
|
||
function sortSessionsByActivity(rows) {
|
||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||
}
|
||
|
||
// Tab grouping by state (`tabArrangement: 'state'`, Discussion #426 option C).
|
||
//
|
||
// The tab list answers "who wants me?" the way the home screens do: a row (the
|
||
// header strip) or a section (the flat side rail, the sidebar) per state, most
|
||
// urgent on top. The states are the home screens' own, from
|
||
// `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
|
||
// four groups a strip can hold:
|
||
//
|
||
// needs red: a permission or question dialog is blocking the agent. A
|
||
// failed session joins it, since it also needs a human and the home
|
||
// screens rank it right below.
|
||
// waiting yellow: the agent finished its turn and is waiting on you.
|
||
// working a turn is running.
|
||
// idle everything quiet: idle, ended, and an agent that exited inside a
|
||
// live pane (#446), which may still read as working on screen but is
|
||
// running nothing. Web tabs close the group.
|
||
//
|
||
// Applied as the flex `order` property, never by reordering the DOM, the same
|
||
// design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
|
||
// in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
|
||
// the list they always read, and a state change moves one inline style instead
|
||
// of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
|
||
// values: its heading at the start of the band, its rows after it, its web tabs
|
||
// after those and the line break that ends the header row at the very end.
|
||
//
|
||
// Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
|
||
// `quiet` groups keep their heading element (it anchors the row and holds the
|
||
// row's place beside the brand) but draw no text: everything quiet is the
|
||
// default state of a tab, so naming it only adds noise.
|
||
const TAB_TRIAGE_GROUPS = [
|
||
{ key: 'needs', label: 'Needs you' },
|
||
{ key: 'waiting', label: 'Waiting' },
|
||
{ key: 'working', label: 'Working' },
|
||
{ key: 'idle', label: 'Idle', quiet: true },
|
||
];
|
||
|
||
const TAB_TRIAGE_GROUP_OF_STATE = {
|
||
needs: 'needs',
|
||
error: 'needs',
|
||
waiting: 'waiting',
|
||
working: 'working',
|
||
idle: 'idle',
|
||
done: 'idle',
|
||
};
|
||
|
||
const TAB_TRIAGE_STRIDE = 10000;
|
||
/** Offset of a group's web tabs inside its band, past any plausible session count. */
|
||
const TAB_TRIAGE_WEB_OFFSET = 5000;
|
||
|
||
/**
|
||
* Which group a session belongs to.
|
||
* @param {string} state a `_mobileOverviewState()` value
|
||
* @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
|
||
* @returns {'needs'|'waiting'|'working'|'idle'}
|
||
*/
|
||
function tabTriageGroupFor(state, exited) {
|
||
const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
|
||
return exited && group === 'working' ? 'idle' : group;
|
||
}
|
||
|
||
/**
|
||
* Order values and visible groups for one pass.
|
||
*
|
||
* @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
|
||
* live sessions; `pos` ranks a row inside its group (tab order on the header
|
||
* strip, the activity sort's position on a sorted rail). Rows without one keep
|
||
* the order they were passed in.
|
||
* @param {Array<string>} webviewIds open web tabs, in their own tab order
|
||
* @param {{reverse?: boolean}} [options] `reverse` puts the groups the other
|
||
* way up (`tabStateOrder: 'urgent-last'`): idle first, needs you last, for a
|
||
* strip read from the bottom. Rows inside a group keep their order.
|
||
* @returns {{
|
||
* order: Map<string, number>,
|
||
* webOrder: Map<string, number>,
|
||
* groups: Array<{key: string, label: string, quiet: boolean, count: number, headOrder: number, breakOrder: number}>
|
||
* }} `groups` lists only the non-empty groups, in display order (most urgent
|
||
* first, or last with `reverse`).
|
||
*/
|
||
function computeTabTriageLayout(rows, webviewIds, options) {
|
||
const list = Array.isArray(rows) ? rows : [];
|
||
const webs = Array.isArray(webviewIds) ? webviewIds : [];
|
||
const sequence = options && options.reverse ? TAB_TRIAGE_GROUPS.slice().reverse() : TAB_TRIAGE_GROUPS;
|
||
const baseOf = {};
|
||
const counts = {};
|
||
sequence.forEach((group, i) => {
|
||
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
|
||
counts[group.key] = 0;
|
||
});
|
||
|
||
const placed = list
|
||
.filter((row) => row && typeof row.id === 'string')
|
||
.map((row, i) => ({
|
||
id: row.id,
|
||
group: tabTriageGroupFor(row.state, !!row.exited),
|
||
pos: Number.isFinite(row.pos) ? row.pos : i,
|
||
index: i,
|
||
}));
|
||
const byGroup = {};
|
||
for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
|
||
|
||
const order = new Map();
|
||
for (const key of Object.keys(byGroup)) {
|
||
// Stable: equal positions keep the order the caller passed.
|
||
byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
|
||
byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
|
||
counts[key] = byGroup[key].length;
|
||
}
|
||
|
||
const webOrder = new Map();
|
||
webs.forEach((id, i) => {
|
||
if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
|
||
});
|
||
counts.idle += webOrder.size;
|
||
|
||
const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
|
||
key: group.key,
|
||
label: group.label,
|
||
quiet: !!group.quiet,
|
||
count: counts[group.key],
|
||
headOrder: baseOf[group.key],
|
||
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
|
||
}));
|
||
|
||
return { order, webOrder, groups };
|
||
}
|
||
|
||
// Tab clusters by case (`tabArrangement: 'case'`, Discussion #426 option A).
|
||
//
|
||
// One cluster per case, in the order the case first appears in the tab order,
|
||
// so the strip keeps the user's arrangement at the case level. Inside a cluster
|
||
// with two or more tabs the `-<case>` part of a generated `w<n>-<case>` name is
|
||
// redundant and is hidden (`tabClusterNameSplit()`), which is what lets 18 tabs
|
||
// read as 7 things. A case with one tab is still its own (unlabelled) box.
|
||
//
|
||
// Cluster colours come from the session palette (`--session-<colour>`) by a
|
||
// stable hash of the case key, so a case keeps its colour across reloads and
|
||
// devices without anything being stored.
|
||
//
|
||
// Pure: no DOM, no `this`. Unit-tested in test/tab-clusters.test.ts.
|
||
const TAB_CLUSTER_COLORS = ['blue', 'green', 'purple', 'orange', 'pink', 'yellow', 'red'];
|
||
|
||
/** Palette colour for a cluster key: a djb2 hash, so the same key always gets the same colour. */
|
||
function tabClusterColorFor(key) {
|
||
const text = String(key || '');
|
||
let hash = 5381;
|
||
for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
||
return TAB_CLUSTER_COLORS[Math.abs(hash) % TAB_CLUSTER_COLORS.length];
|
||
}
|
||
|
||
/**
|
||
* Group tabs by case.
|
||
* @param {Array<{id: string, key: string, label: string}>} rows live sessions in
|
||
* tab order; `key` identifies the case (its path), `label` names it
|
||
* @returns {Array<{key: string, label: string, color: string, ids: string[]}>}
|
||
* clusters in first-appearance order, members in tab order
|
||
*/
|
||
function computeTabClusters(rows) {
|
||
const clusters = [];
|
||
const byKey = new Map();
|
||
for (const row of Array.isArray(rows) ? rows : []) {
|
||
if (!row || typeof row.id !== 'string') continue;
|
||
const key = typeof row.key === 'string' && row.key ? row.key : `session:${row.id}`;
|
||
let cluster = byKey.get(key);
|
||
if (!cluster) {
|
||
cluster = { key, label: typeof row.label === 'string' ? row.label : '', color: tabClusterColorFor(key), ids: [] };
|
||
byKey.set(key, cluster);
|
||
clusters.push(cluster);
|
||
}
|
||
cluster.ids.push(row.id);
|
||
}
|
||
return clusters;
|
||
}
|
||
|
||
/**
|
||
* Split a generated `w<n>-<case>` / `s<n>-<case>` name into the part a cluster
|
||
* shows and the case suffix it hides, or null when the name is anything else
|
||
* (a custom name, a described `w3-x: fix login`, another case's name). Case is
|
||
* compared case-insensitively; the hidden part keeps its original spelling so
|
||
* the full name is still in the DOM.
|
||
* @returns {{shown: string, hidden: string}|null}
|
||
*/
|
||
function tabClusterNameSplit(name, label) {
|
||
if (typeof name !== 'string' || typeof label !== 'string' || !label) return null;
|
||
const match = name.match(/^([ws]\d+)(-.+)$/);
|
||
if (!match) return null;
|
||
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
|
||
}
|
||
|
||
/**
|
||
* Session-list search: the vertical rail's search box and the sidebar's filter
|
||
* box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
|
||
* Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
|
||
* Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
|
||
* @param {unknown} query
|
||
* @returns {string} the needle, '' when there is nothing to search for
|
||
*/
|
||
function tabSearchNeedle(query) {
|
||
return typeof query === 'string' ? query.trim().toLowerCase() : '';
|
||
}
|
||
|
||
/**
|
||
* Which rows a search hides. Pure: the caller reads the rows off the list it
|
||
* rendered and applies the result as classes, so the list itself (grouping,
|
||
* order, Alt+N badges) is never rebuilt or reordered by a search.
|
||
*
|
||
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
|
||
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
|
||
* view filter). It counts toward its section, so its group stays on screen with
|
||
* it, but not toward `matchCount`.
|
||
*
|
||
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
|
||
* in list order; `section` is the row's group or case box, null/undefined for none.
|
||
* @param {unknown} query
|
||
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
|
||
* `counts` is the rows left showing per section (kept rows included), every
|
||
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
|
||
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
|
||
*/
|
||
function filterTabSearchRows(rows, query) {
|
||
const needle = tabSearchNeedle(query);
|
||
const hidden = new Set();
|
||
const counts = new Map();
|
||
let matchCount = 0;
|
||
for (const row of Array.isArray(rows) ? rows : []) {
|
||
const hasSection = row.section !== null && row.section !== undefined;
|
||
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
|
||
const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
|
||
const matches = !needle || text.includes(needle);
|
||
if (!matches && row.keep !== true) {
|
||
hidden.add(row.key);
|
||
continue;
|
||
}
|
||
if (matches) matchCount++;
|
||
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
|
||
}
|
||
return { active: needle.length > 0, hidden, counts, matchCount };
|
||
}
|
||
|
||
// Terminal font stack — the single source for every xterm surface (the main
|
||
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||
// @font-face in styles.css): browsers fall back PER GLYPH, so Nerd Font
|
||
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
|
||
// oh-my-posh) render even though the text fonts carry no private-use-area
|
||
// symbols — while all readable text keeps coming from the text fonts.
|
||
/**
|
||
* How long a terminal fit will wait for the terminal font, in ms.
|
||
*
|
||
* `FontFaceSet.ready` has no deadline of its own and the wait sits in front of
|
||
* the buffer replay, so a font request that never settles would leave the
|
||
* session unpainted. Past this we measure whatever is painted.
|
||
*/
|
||
const TERMINAL_FONT_WAIT_MS = 2000;
|
||
|
||
/**
|
||
* Families in the stack that cannot move the measured cell, so nothing waits on
|
||
* them: the generics match no `FontFace`, and the bundled symbols face carries
|
||
* private-use-area glyphs only (xterm measures `W`) while weighing ~1.2MB.
|
||
*/
|
||
const TERMINAL_FONT_UNMEASURED = new Set(['monospace', 'serif', 'sans-serif', 'system-ui', 'symbols nerd font mono']);
|
||
|
||
const TERMINAL_FONT_DEFAULT_STACK =
|
||
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
|
||
|
||
/**
|
||
* Resolve the xterm fontFamily from the per-device `terminalFontFamily`
|
||
* setting. A user-set family (or comma-separated list) is PREPENDED to the
|
||
* built-in stack, never a replacement — the symbols fallback and a final
|
||
* `monospace` must survive whatever the user types. Blank input yields the
|
||
* default. Unquoted names that need quoting for CSS (spaces, digits leading,
|
||
* etc.) are quoted; embedded quotes are stripped rather than escaped, since
|
||
* a font name cannot contain them anyway.
|
||
*/
|
||
function resolveTerminalFontFamily(custom) {
|
||
const raw = typeof custom === 'string' ? custom.trim() : '';
|
||
if (!raw) return TERMINAL_FONT_DEFAULT_STACK;
|
||
const families = raw
|
||
.split(',')
|
||
.map((f) => f.trim().replace(/^["']|["']$/g, '').replace(/["']/g, '').trim())
|
||
.filter(Boolean)
|
||
// Drop generic families the user may append — the default stack already
|
||
// ends in `monospace`, and a duplicate earlier entry would shadow the
|
||
// symbols fallback behind it.
|
||
.filter((f) => !/^(monospace|serif|sans-serif|system-ui)$/i.test(f))
|
||
.map((f) => (/^[A-Za-z][A-Za-z0-9-]*$/.test(f) ? f : `"${f}"`));
|
||
if (!families.length) return TERMINAL_FONT_DEFAULT_STACK;
|
||
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
|
||
}
|
||
|
||
/**
|
||
* xterm's own defaults for the two weight slots, one per slot.
|
||
*
|
||
* They are deliberately kept apart rather than collapsed into a single
|
||
* fallback: handing the bold slot `normal` (or the normal slot `bold`) would
|
||
* turn an unset setting into a visible change, which is exactly the thing this
|
||
* feature exists to make controllable.
|
||
*/
|
||
const TERMINAL_FONT_WEIGHT_DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' };
|
||
|
||
/**
|
||
* Resolve ONE weight slot against xterm's validation rules.
|
||
*
|
||
* xterm accepts a number in 1..1000, or one of its own keyword/numeric-string
|
||
* options, and silently falls back to the slot default for anything else
|
||
* (`OptionsService._sanitizeAndValidateOption`). Resolving here instead means a
|
||
* stored value the picker does not list (a hand-set 350) still reaches the
|
||
* terminal, while junk in localStorage never does.
|
||
*/
|
||
function resolveTerminalFontWeightSlot(value, fallback) {
|
||
if (value === 'normal' || value === 'bold') return value;
|
||
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN;
|
||
if (!Number.isFinite(numeric) || numeric < 1 || numeric > 1000) return fallback;
|
||
return Math.round(numeric);
|
||
}
|
||
|
||
/**
|
||
* Resolve both xterm weight slots from the per-device settings blob.
|
||
*
|
||
* Bold text on the theme's default foreground carries exactly ONE cue, the
|
||
* weight step: Claude Code marks its markdown bold with a bare `ESC[1m` and no
|
||
* colour, and xterm's bold-to-bright substitution only fires for palette
|
||
* indices 0-7, so it never applies to default-foreground text. A family that
|
||
* ships only a regular and a bold face keeps that step small, and 400 stays
|
||
* 400 whatever family is chosen — lowering the NORMAL weight is the only way
|
||
* to widen the gap.
|
||
*/
|
||
function resolveTerminalFontWeights(settings) {
|
||
const s = settings && typeof settings === 'object' ? settings : {};
|
||
return {
|
||
fontWeight: resolveTerminalFontWeightSlot(s.terminalFontWeight, TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeight),
|
||
fontWeightBold: resolveTerminalFontWeightSlot(
|
||
s.terminalFontWeightBold,
|
||
TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeightBold
|
||
),
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auto Copy (copy-on-select). Pure decision, so every guard below is testable
|
||
// without a terminal, a clipboard, or a browser.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Largest single input frame the server accepts, in UTF-16 code units.
|
||
* ⚠️ Must equal MAX_INPUT_LENGTH in src/config/terminal-limits.ts (pinned by
|
||
* test/input-size-limit.test.ts). Both transports reject a longer frame, and
|
||
* before issue #484 the durable input queue retried such a frame forever.
|
||
*/
|
||
const INPUT_FRAME_MAX_CHARS = 64 * 1024;
|
||
|
||
/**
|
||
* Largest paste the client will deliver at all. Anything up to this is split
|
||
* into INPUT_FRAME_MAX_CHARS frames that go out in seq order, so the PTY sees
|
||
* one contiguous byte stream (bracketed-paste markers included). Past it the
|
||
* input is refused with a toast rather than queued: every frame is persisted
|
||
* and retried until ACKed, so a multi-megabyte paste would pin the queue.
|
||
*/
|
||
const INPUT_PASTE_MAX_CHARS = 1024 * 1024;
|
||
|
||
/**
|
||
* Split input into frames no longer than `max` code units, never cutting a
|
||
* surrogate pair in half (a lone surrogate reaches the PTY as U+FFFD).
|
||
*
|
||
* @param {string} data
|
||
* @param {number} [max]
|
||
* @returns {string[]}
|
||
*/
|
||
function splitInputFrames(data, max = INPUT_FRAME_MAX_CHARS) {
|
||
if (typeof data !== 'string' || data.length === 0) return [];
|
||
if (!(max >= 2)) max = 2;
|
||
if (data.length <= max) return [data];
|
||
const frames = [];
|
||
let start = 0;
|
||
while (start < data.length) {
|
||
let end = Math.min(start + max, data.length);
|
||
if (end < data.length) {
|
||
const code = data.charCodeAt(end - 1);
|
||
if (code >= 0xd800 && code <= 0xdbff) end--; // keep the pair together
|
||
}
|
||
frames.push(data.slice(start, end));
|
||
start = end;
|
||
}
|
||
return frames;
|
||
}
|
||
|
||
/**
|
||
* Upper bound on an AUTO-copied selection.
|
||
*
|
||
* A drag that runs off the top of the viewport autoscrolls, so one gesture can
|
||
* sweep the entire 50k-line scrollback (millions of characters), and writing
|
||
* that to the clipboard on every mouseup is a real hazard on a phone. Past the
|
||
* cap the copy is REFUSED rather than truncated (half a selection on the
|
||
* clipboard is worse than none) and the user is told to press Ctrl+C, which
|
||
* still copies the whole thing through the explicit path.
|
||
*/
|
||
const AUTO_COPY_MAX_CHARS = 1_000_000;
|
||
|
||
/**
|
||
* What an auto-copy attempt should do at the end of a selection gesture.
|
||
*
|
||
* `pending` is set by xterm's onSelectionChange and cleared on every flush;
|
||
* `lastCopied` is the text this surface auto-copied last. Either one alone is
|
||
* wrong, which is why both are here:
|
||
*
|
||
* - onSelectionChange does not reliably fire BEFORE the mouseup that ends the
|
||
* drag (xterm fires it from its own document-level mouseup handler, and
|
||
* listener order between the two is registration order, not something this
|
||
* code controls). Gating on `pending` alone would silently drop the first
|
||
* copy of a drag-selection.
|
||
* - Gating on `text !== lastCopied` alone drops a deliberate re-selection of
|
||
* the same text after the user copied something else in between, and it
|
||
* would let any unrelated mouseup on the page re-copy a stale selection.
|
||
*
|
||
* So: a genuine selection change (`pending`) always copies, and otherwise only
|
||
* text that differs from the last auto-copy does.
|
||
*
|
||
* @param {{enabled?: boolean, text?: string, lastCopied?: string, pending?: boolean}} params
|
||
* @returns {'copy'|'skip'|'too-large'}
|
||
*/
|
||
function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
|
||
if (!enabled) return 'skip';
|
||
// Whitespace-only is what a drag across blank cells produces; putting a wall
|
||
// of spaces on the clipboard is never what the gesture meant.
|
||
if (typeof text !== 'string' || !text.trim()) return 'skip';
|
||
if (!pending && text === lastCopied) return 'skip';
|
||
if (text.length > AUTO_COPY_MAX_CHARS) return 'too-large';
|
||
return 'copy';
|
||
}
|
||
|
||
// The text a copy should put on the clipboard, given xterm's raw selection.
|
||
// Pure: the caller reads the selection and decides the mode, this transforms.
|
||
//
|
||
// xterm hands back whole screen ROWS, and its own trim only drops cells that
|
||
// were never written to. A full-screen TUI writes real spaces across the part
|
||
// of a row it is not using, so that padding counts as content and rides along
|
||
// to the clipboard: measured against Claude Code in a 282-column pane, single
|
||
// lines arrived carrying 138 trailing spaces. Native terminals trim it on copy
|
||
// (Windows Terminal, iTerm2 and GNOME Terminal all do), decideAutoCopy above
|
||
// already calls a wall of spaces "never what the gesture meant", and
|
||
// _selectTouchSelectionLine already treats those cells as padding. This is that
|
||
// same rule for the mouse and keyboard paths, which never had it.
|
||
//
|
||
// A LEADING margin is stripped too, but only the one the CLI in the pane
|
||
// DECLARES as its transcript gutter, passed in as `options.margin`. Called with
|
||
// no options this trims trailing padding and nothing else, which is what keeps
|
||
// every caller that has no declared gutter on the old behaviour.
|
||
//
|
||
// ⚠ The failure modes are not symmetrical, and that asymmetry sets how much
|
||
// evidence a leading strip has to show before it fires. A wrong trailing trim
|
||
// costs nothing. A wrong dedent silently deletes information that was on the
|
||
// screen, with no signal to the user and nothing in the clipboard to hint at
|
||
// it, and it is wrong on `git log` bodies, on indented code read out of `cat`
|
||
// (semantic in Python), on `git diff` context rows where the leading space is
|
||
// the marker, and on stack traces.
|
||
//
|
||
// ⚠ The declared gutter is a CEILING, not the answer. The strip is the lesser
|
||
// of it and the run every selected line shares, so a block can only ever shift
|
||
// as a unit: the relative structure inside a selection survives by
|
||
// construction, and a selection reaching column 0 loses nothing at all.
|
||
//
|
||
// ⚠ Deriving the width from the text instead is what fails, twice over. The
|
||
// selection's own shared indent cannot tell a margin from content, because a
|
||
// three-row window of nested YAML shares an indent for the same reason a margin
|
||
// does — it fired on 73% of ordinary indented text. Taking the narrowest indent
|
||
// on the surrounding rows fails more quietly: a file listing inside the
|
||
// transcript can be the narrowest thing on screen, which over-stripped about 1%
|
||
// of selections across six pane widths.
|
||
function cleanCopiedSelection(text, options) {
|
||
if (typeof text !== 'string' || !text) return '';
|
||
// Split on \n and leave any \r in place: xterm joins rows with \r\n on
|
||
// Windows, and the clipboard should keep the endings xterm chose.
|
||
// Scanned rather than matched. A selection can run to the 50 000-row
|
||
// scrollback ceiling, and `/[ \t]+(\r?)$/` is QUADRATIC on a line whose spaces
|
||
// are followed by any non-space character, which is what right-aligned or
|
||
// centred TUI content looks like: the engine retries the run from every
|
||
// whitespace position and backtracks over it. Measured over 50 000 rows with a
|
||
// 280-column run, that regex took 2.9s against 1.3ms for the scan below, and a
|
||
// 2 000-column run took 16s. It is also the faster of the two on an ordinary
|
||
// padded row. A length is returned rather than a trimmed string so a
|
||
// \r-terminated line costs no substring either.
|
||
const trimEnd = (line) => {
|
||
let end = line.length;
|
||
if (end > 0 && line[end - 1] === '\r') end--;
|
||
let cut = end;
|
||
while (cut > 0 && (line[cut - 1] === ' ' || line[cut - 1] === '\t')) cut--;
|
||
return cut === end ? line : line.slice(0, cut) + line.slice(end);
|
||
};
|
||
const lines = text.split('\n');
|
||
for (let i = 0; i < lines.length; i++) lines[i] = trimEnd(lines[i]);
|
||
|
||
const margin = Math.max(0, Math.trunc(Number(options?.margin) || 0));
|
||
if (!margin) return lines.join('\n');
|
||
|
||
// The first line of a selection that began mid-row carries no margin — the
|
||
// mousedown cut it off — so it neither votes on the shared indent nor gets
|
||
// stripped. This is the ONE thing the mousedown column still decides, and it
|
||
// decides it for that line alone. Whether the rest of the block is dedented
|
||
// no longer depends on where the click landed, which is what made the same
|
||
// three rows produce three different clipboard results before.
|
||
const from = options?.firstLinePartial === true ? 1 : 0;
|
||
|
||
// The pane's margin is a ceiling, not the answer. Strip the narrower of it
|
||
// and what every selected line shares, so the block shifts as a unit and no
|
||
// line can lose indentation another line keeps.
|
||
let shared = margin;
|
||
for (let i = from; i < lines.length && shared > 0; i++) {
|
||
const line = lines[i];
|
||
if (!line || line === '\r') continue; // a padding-only row, already trimmed away
|
||
let run = 0;
|
||
while (run < line.length && line[run] === ' ') run++;
|
||
if (run < shared) shared = run;
|
||
}
|
||
if (!shared) return lines.join('\n');
|
||
for (let i = from; i < lines.length; i++) {
|
||
if (lines[i] && lines[i] !== '\r') lines[i] = lines[i].slice(shared);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ── Markdown heading anchors ────────────────────────────────────────────────
|
||
// marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
|
||
// nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
|
||
// root, not at the page, so letting the browser follow it navigates the app away. The click delegate
|
||
// (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
|
||
// below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
|
||
// the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
|
||
|
||
/**
|
||
* GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
|
||
* dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
|
||
*/
|
||
function markdownHeadingSlug(text) {
|
||
return String(text ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
|
||
.replace(/ /g, '-');
|
||
}
|
||
|
||
/** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
|
||
function assignMarkdownHeadingAnchors(root) {
|
||
const used = new Set();
|
||
for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||
const base = markdownHeadingSlug(heading.textContent);
|
||
let slug = base;
|
||
for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
|
||
used.add(slug);
|
||
heading.dataset.mdAnchor = slug;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
|
||
* points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
|
||
* slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
|
||
* only (never `document.getElementById`, which could find an app element of the same name).
|
||
*/
|
||
function findMarkdownAnchorTarget(root, href) {
|
||
let fragment = String(href ?? '').replace(/^#/, '');
|
||
try {
|
||
fragment = decodeURIComponent(fragment);
|
||
} catch {
|
||
/* a malformed escape: use it as written */
|
||
}
|
||
if (!fragment) return root;
|
||
assignMarkdownHeadingAnchors(root);
|
||
const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
|
||
for (const heading of root.querySelectorAll('[data-md-anchor]')) {
|
||
if (wanted.includes(heading.dataset.mdAnchor)) return heading;
|
||
}
|
||
for (const el of root.querySelectorAll('[id]')) {
|
||
if (el.id === fragment) return el;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||
window.shouldSkipWebGL = shouldSkipWebGL;
|
||
window.CodemanTabOverflow = {
|
||
shouldAutoWrapTabs,
|
||
resolveTabOrientation,
|
||
computeTabScrollLeft,
|
||
TAB_SCROLL_REVEAL_PX,
|
||
};
|
||
window.CodemanTabRail = {
|
||
DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH,
|
||
RICH_DEFAULT_WIDTH: TAB_RAIL_RICH_DEFAULT_WIDTH,
|
||
MIN_WIDTH: TAB_RAIL_MIN_WIDTH,
|
||
MAX_WIDTH: TAB_RAIL_MAX_WIDTH,
|
||
resolveWidth: resolveTabRailWidth,
|
||
resolveKeyboardWidth: resolveTabRailKeyboardWidth,
|
||
};
|
||
window.CodemanWsReconnect = {
|
||
plan: planWsReconnect,
|
||
};
|
||
window.CodemanLineage = {
|
||
computeTree: computeLineageTree,
|
||
computeRows: computeLineageRows,
|
||
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
|
||
LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
|
||
MAX_LANES: LINEAGE_MAX_LANES,
|
||
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
|
||
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
|
||
COLORS: LINEAGE_COLORS,
|
||
};
|
||
window.CodemanConnectionLoss = {
|
||
compute: computeConnectionLossUi,
|
||
GRACE_MS: CONNECTION_LOSS_GRACE_MS,
|
||
};
|
||
window.CodemanSseStale = {
|
||
compute: computeSseStale,
|
||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||
CONNECT_TIMEOUT_MS: SSE_CONNECT_TIMEOUT_MS,
|
||
};
|
||
window.CodemanPromptCache = {
|
||
format: formatPromptCacheStatus,
|
||
formatKeepWarm: formatKeepWarmStatus,
|
||
keepWarmUnavailable: (statusLine) => !!PROMPT_CACHE_NOT_COLLECTED[statusLine],
|
||
};
|
||
window.CodemanSessionOrder = {
|
||
RANK: SESSION_ACTIVITY_RANK,
|
||
anchor: sessionActivityAnchor,
|
||
compare: compareSessionActivity,
|
||
sort: sortSessionsByActivity,
|
||
};
|
||
window.CodemanTabClusters = {
|
||
COLORS: TAB_CLUSTER_COLORS,
|
||
colorFor: tabClusterColorFor,
|
||
compute: computeTabClusters,
|
||
nameSplit: tabClusterNameSplit,
|
||
};
|
||
window.CodemanTabTriage = {
|
||
GROUPS: TAB_TRIAGE_GROUPS,
|
||
STRIDE: TAB_TRIAGE_STRIDE,
|
||
groupFor: tabTriageGroupFor,
|
||
layout: computeTabTriageLayout,
|
||
};
|
||
window.CodemanTabSearch = {
|
||
needle: tabSearchNeedle,
|
||
filter: filterTabSearchRows,
|
||
};
|
||
window.CodemanInputLimit = {
|
||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||
split: splitInputFrames,
|
||
};
|
||
window.CodemanAutoCopy = {
|
||
decide: decideAutoCopy,
|
||
MAX_CHARS: AUTO_COPY_MAX_CHARS,
|
||
};
|
||
window.CodemanCopySelection = {
|
||
clean: cleanCopiedSelection,
|
||
};
|
||
window.CodemanMarkdownAnchors = {
|
||
slug: markdownHeadingSlug,
|
||
assign: assignMarkdownHeadingAnchors,
|
||
find: findMarkdownAnchorTarget,
|
||
};
|
||
window.CodemanTerminalFont = {
|
||
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
||
resolve: resolveTerminalFontFamily,
|
||
WEIGHT_DEFAULTS: TERMINAL_FONT_WEIGHT_DEFAULTS,
|
||
resolveWeights: resolveTerminalFontWeights,
|
||
};
|
||
}
|
||
|
||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||
// scheduler.postTask('background') defers non-critical work (connection lines, panel renders)
|
||
// so the main thread stays free for terminal rendering at 60fps.
|
||
const _hasScheduler = typeof globalThis.scheduler?.postTask === 'function';
|
||
function scheduleBackground(fn) {
|
||
if (_hasScheduler) { scheduler.postTask(fn, { priority: 'background' }); }
|
||
else { requestAnimationFrame(fn); }
|
||
}
|
||
|
||
// DEC mode 2026 marker stripping — xterm.js 6.0 handles sync natively,
|
||
// but server-sent terminal buffers may still contain markers from Claude CLI.
|
||
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
||
|
||
// OSC 52 clipboard writes stripping — a replay re-feeds the raw byte history, and
|
||
// xterm parses it after chunkedTerminalWrite has already released the load
|
||
// gate, so an OSC 52 left in would re-copy old text on every tab switch. The
|
||
// 8-bit C1 forms (OSC \x9d, ST \x9c) too: xterm.js still parses those.
|
||
const OSC52_STRIP_RE = /(?:\x1b\]|\x9d)52;[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g;
|
||
// The same OSC 52 text arriving again within this window is one copy delivered
|
||
// several ways (direct, tmux load-buffer -w, tmux set-clipboard forwarding).
|
||
const OSC52_DEDUPE_MS = 1000;
|
||
|
||
// Built-in respawn configuration presets
|
||
const BUILTIN_RESPAWN_PRESETS = [
|
||
{
|
||
id: 'solo-work',
|
||
name: 'Solo',
|
||
description: 'Claude working alone — fast respawn cycles with context reset',
|
||
config: {
|
||
idleTimeoutMs: 3000,
|
||
updatePrompt: 'summarize your progress so far before the context reset.',
|
||
interStepDelayMs: 2000,
|
||
sendClear: true,
|
||
sendInit: true,
|
||
kickstartPrompt: 'continue working. Pick up where you left off based on the context above.',
|
||
autoAcceptPrompts: true,
|
||
},
|
||
durationMinutes: 60,
|
||
builtIn: true,
|
||
createdAt: 0,
|
||
},
|
||
{
|
||
id: 'subagent-workflow',
|
||
name: 'Subagents',
|
||
description: 'Lead session with Task tool subagents — longer idle tolerance',
|
||
config: {
|
||
idleTimeoutMs: 45000,
|
||
updatePrompt: 'check on your running subagents and summarize their results before the context reset. If all subagents have finished, note what was completed and what remains.',
|
||
interStepDelayMs: 3000,
|
||
sendClear: true,
|
||
sendInit: true,
|
||
kickstartPrompt: 'check on your running subagents and continue coordinating their work. If all subagents have finished, summarize their results and proceed with the next step.',
|
||
autoAcceptPrompts: true,
|
||
},
|
||
durationMinutes: 240,
|
||
builtIn: true,
|
||
createdAt: 0,
|
||
},
|
||
{
|
||
id: 'team-lead',
|
||
name: 'Team',
|
||
description: 'Leading an agent team via TeamCreate — tolerates long silences',
|
||
config: {
|
||
idleTimeoutMs: 90000,
|
||
updatePrompt: 'review the task list and teammate progress. Summarize the current state before the context reset.',
|
||
interStepDelayMs: 5000,
|
||
sendClear: true,
|
||
sendInit: true,
|
||
kickstartPrompt: 'check on your teammates by reviewing the task list and any messages in your inbox. Assign new tasks if teammates are idle, or continue coordinating the team effort.',
|
||
autoAcceptPrompts: true,
|
||
},
|
||
durationMinutes: 480,
|
||
builtIn: true,
|
||
createdAt: 0,
|
||
},
|
||
{
|
||
id: 'ralph-todo',
|
||
name: 'Ralph/Todo',
|
||
description: 'Ralph Loop task list — works through todos with progress tracking',
|
||
config: {
|
||
idleTimeoutMs: 8000,
|
||
updatePrompt: 'update CLAUDE.md with discoveries and progress notes, mark completed tasks in @fix_plan.md, write a brief summary so the next cycle can continue seamlessly.',
|
||
interStepDelayMs: 3000,
|
||
sendClear: true,
|
||
sendInit: true,
|
||
kickstartPrompt: 'read @fix_plan.md for task status, continue on the next uncompleted task. When ALL tasks are complete, output <promise>COMPLETE</promise>.',
|
||
autoAcceptPrompts: true,
|
||
},
|
||
durationMinutes: 480,
|
||
builtIn: true,
|
||
createdAt: 0,
|
||
},
|
||
{
|
||
id: 'overnight-autonomous',
|
||
name: 'Overnight',
|
||
description: 'Unattended overnight runs with full context reset between cycles',
|
||
config: {
|
||
idleTimeoutMs: 10000,
|
||
updatePrompt: 'summarize what you accomplished so far and write key progress notes to CLAUDE.md so the next cycle can pick up where you left off.',
|
||
interStepDelayMs: 3000,
|
||
sendClear: true,
|
||
sendInit: true,
|
||
kickstartPrompt: 'continue working on the task. Pick up where you left off based on the context above.',
|
||
autoAcceptPrompts: true,
|
||
},
|
||
durationMinutes: 480,
|
||
builtIn: true,
|
||
createdAt: 0,
|
||
},
|
||
];
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// SSE Event Types
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** @type {Record<string, string>} Centralized SSE event type constants */
|
||
const SSE_EVENTS = {
|
||
// Core
|
||
INIT: 'init',
|
||
|
||
// Transport
|
||
HEARTBEAT: 'sse:heartbeat',
|
||
|
||
// Session lifecycle
|
||
SESSION_CREATED: 'session:created',
|
||
SESSION_UPDATED: 'session:updated',
|
||
SESSION_DELETED: 'session:deleted',
|
||
SESSION_TERMINAL: 'session:terminal',
|
||
SESSION_NEEDS_REFRESH: 'session:needsRefresh',
|
||
SESSION_CLEAR_TERMINAL: 'session:clearTerminal',
|
||
SESSION_COMPLETION: 'session:completion',
|
||
SESSION_ERROR: 'session:error',
|
||
SESSION_EXIT: 'session:exit',
|
||
SESSION_IDLE: 'session:idle',
|
||
SESSION_WORKING: 'session:working',
|
||
SESSION_AUTO_CLEAR: 'session:autoClear',
|
||
SESSION_AUTO_COMPACT: 'session:autoCompact',
|
||
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
|
||
SESSION_LIMIT_RESUME: 'session:limitResume',
|
||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||
SESSION_CLI_INFO: 'session:cliInfo',
|
||
SESSION_PINNED: 'session:pinned',
|
||
SESSION_MESSAGE: 'session:message',
|
||
SESSION_INTERACTIVE: 'session:interactive',
|
||
SESSION_RUNNING: 'session:running',
|
||
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
||
SESSION_PROMPT_CACHE: 'session:promptCache',
|
||
SESSION_KEEP_WARM: 'session:keepWarm',
|
||
|
||
// Scheduled runs
|
||
SCHEDULED_CREATED: 'scheduled:created',
|
||
SCHEDULED_UPDATED: 'scheduled:updated',
|
||
SCHEDULED_COMPLETED: 'scheduled:completed',
|
||
SCHEDULED_STOPPED: 'scheduled:stopped',
|
||
SCHEDULED_LOG: 'scheduled:log',
|
||
SCHEDULED_DELETED: 'scheduled:deleted',
|
||
|
||
// Cron jobs
|
||
CRON_JOBS_CHANGED: 'cron:jobsChanged',
|
||
CRON_JOB_DELETED: 'cron:jobDeleted',
|
||
CRON_RUN_CREATED: 'cron:runCreated',
|
||
CRON_RUN_UPDATED: 'cron:runUpdated',
|
||
|
||
// Respawn
|
||
RESPAWN_STARTED: 'respawn:started',
|
||
RESPAWN_STOPPED: 'respawn:stopped',
|
||
RESPAWN_STATE_CHANGED: 'respawn:stateChanged',
|
||
RESPAWN_CYCLE_STARTED: 'respawn:cycleStarted',
|
||
RESPAWN_CYCLE_COMPLETED: 'respawn:cycleCompleted',
|
||
RESPAWN_BLOCKED: 'respawn:blocked',
|
||
RESPAWN_STEP_SENT: 'respawn:stepSent',
|
||
RESPAWN_STEP_COMPLETED: 'respawn:stepCompleted',
|
||
RESPAWN_DETECTION_UPDATE: 'respawn:detectionUpdate',
|
||
RESPAWN_AUTO_ACCEPT_SENT: 'respawn:autoAcceptSent',
|
||
RESPAWN_AI_CHECK_STARTED: 'respawn:aiCheckStarted',
|
||
RESPAWN_AI_CHECK_COMPLETED: 'respawn:aiCheckCompleted',
|
||
RESPAWN_AI_CHECK_FAILED: 'respawn:aiCheckFailed',
|
||
RESPAWN_AI_CHECK_COOLDOWN: 'respawn:aiCheckCooldown',
|
||
RESPAWN_PLAN_CHECK_STARTED: 'respawn:planCheckStarted',
|
||
RESPAWN_PLAN_CHECK_COMPLETED: 'respawn:planCheckCompleted',
|
||
RESPAWN_PLAN_CHECK_FAILED: 'respawn:planCheckFailed',
|
||
RESPAWN_TIMER_STARTED: 'respawn:timerStarted',
|
||
RESPAWN_TIMER_CANCELLED: 'respawn:timerCancelled',
|
||
RESPAWN_TIMER_COMPLETED: 'respawn:timerCompleted',
|
||
RESPAWN_ACTION_LOG: 'respawn:actionLog',
|
||
RESPAWN_LOG: 'respawn:log',
|
||
RESPAWN_ERROR: 'respawn:error',
|
||
RESPAWN_CONFIG_UPDATED: 'respawn:configUpdated',
|
||
|
||
// Tasks
|
||
TASK_CREATED: 'task:created',
|
||
TASK_COMPLETED: 'task:completed',
|
||
TASK_FAILED: 'task:failed',
|
||
TASK_UPDATED: 'task:updated',
|
||
|
||
// Mux (tmux)
|
||
MUX_CREATED: 'mux:created',
|
||
MUX_KILLED: 'mux:killed',
|
||
MUX_DIED: 'mux:died',
|
||
MUX_STATS_UPDATED: 'mux:statsUpdated',
|
||
|
||
// Remote auto-reconnect (COD-108)
|
||
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
|
||
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
|
||
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
|
||
// Wake-on-LAN from user input on a sleeping remote host
|
||
REMOTE_HOST_WAKING: 'remote:hostWaking',
|
||
REMOTE_HOST_WAKE_FAILED: 'remote:hostWakeFailed',
|
||
|
||
// Ralph
|
||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||
SESSION_RALPH_TODO_UPDATE: 'session:ralphTodoUpdate',
|
||
SESSION_RALPH_COMPLETION_DETECTED: 'session:ralphCompletionDetected',
|
||
SESSION_RALPH_STATUS_UPDATE: 'session:ralphStatusUpdate',
|
||
SESSION_CIRCUIT_BREAKER_UPDATE: 'session:circuitBreakerUpdate',
|
||
SESSION_EXIT_GATE_MET: 'session:exitGateMet',
|
||
|
||
// Bash tools
|
||
SESSION_BASH_TOOL_START: 'session:bashToolStart',
|
||
SESSION_BASH_TOOL_END: 'session:bashToolEnd',
|
||
SESSION_BASH_TOOLS_UPDATE: 'session:bashToolsUpdate',
|
||
|
||
// Session: Plan
|
||
SESSION_PLAN_TASK_UPDATE: 'session:planTaskUpdate',
|
||
SESSION_PLAN_CHECKPOINT: 'session:planCheckpoint',
|
||
SESSION_PLAN_ROLLBACK: 'session:planRollback',
|
||
SESSION_PLAN_TASK_ADDED: 'session:planTaskAdded',
|
||
|
||
// Hooks (Claude Code hook events)
|
||
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
|
||
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
|
||
HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog',
|
||
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
|
||
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
|
||
HOOK_STOP: 'hook:stop',
|
||
HOOK_AGENT_WORKING: 'hook:agent_working',
|
||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
||
HOOK_PROMPT_SUBMITTED: 'hook:prompt_submitted',
|
||
|
||
// Approvals Inbox
|
||
APPROVAL_PENDING: 'approval:pending',
|
||
APPROVAL_UPDATED: 'approval:updated',
|
||
APPROVAL_RESOLVED: 'approval:resolved',
|
||
|
||
// Custom Model Endpoint Profiles
|
||
CUSTOM_MODEL_SWAPPED_OUT: 'custom-model:swapped-out',
|
||
|
||
// Subagents (Claude Code background agents)
|
||
SUBAGENT_DISCOVERED: 'subagent:discovered',
|
||
SUBAGENT_UPDATED: 'subagent:updated',
|
||
SUBAGENT_TOOL_CALL: 'subagent:tool_call',
|
||
SUBAGENT_PROGRESS: 'subagent:progress',
|
||
SUBAGENT_MESSAGE: 'subagent:message',
|
||
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
|
||
SUBAGENT_COMPLETED: 'subagent:completed',
|
||
|
||
// Workflow runs (ultracode / Workflow tool)
|
||
WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered',
|
||
WORKFLOW_RUN_UPDATED: 'workflow:run_updated',
|
||
WORKFLOW_RUN_REMOVED: 'workflow:run_removed',
|
||
|
||
// Images
|
||
IMAGE_DETECTED: 'image:detected',
|
||
ATTACHMENT_DETECTED: 'attachment:detected',
|
||
|
||
// Tunnel
|
||
TUNNEL_STARTED: 'tunnel:started',
|
||
TUNNEL_STOPPED: 'tunnel:stopped',
|
||
TUNNEL_PROGRESS: 'tunnel:progress',
|
||
TUNNEL_ERROR: 'tunnel:error',
|
||
TUNNEL_QR_ROTATED: 'tunnel:qrRotated',
|
||
TUNNEL_QR_REGENERATED: 'tunnel:qrRegenerated',
|
||
TUNNEL_QR_AUTH_USED: 'tunnel:qrAuthUsed',
|
||
|
||
// Plan orchestration
|
||
PLAN_SUBAGENT: 'plan:subagent',
|
||
PLAN_PROGRESS: 'plan:progress',
|
||
PLAN_STARTED: 'plan:started',
|
||
PLAN_CANCELLED: 'plan:cancelled',
|
||
PLAN_COMPLETED: 'plan:completed',
|
||
|
||
// Orchestrator Loop
|
||
ORCHESTRATOR_STATE_CHANGED: 'orchestrator:stateChanged',
|
||
ORCHESTRATOR_PLAN_PROGRESS: 'orchestrator:planProgress',
|
||
ORCHESTRATOR_PLAN_READY: 'orchestrator:planReady',
|
||
ORCHESTRATOR_PHASE_STARTED: 'orchestrator:phaseStarted',
|
||
ORCHESTRATOR_PHASE_COMPLETED: 'orchestrator:phaseCompleted',
|
||
ORCHESTRATOR_PHASE_FAILED: 'orchestrator:phaseFailed',
|
||
ORCHESTRATOR_VERIFICATION: 'orchestrator:verification',
|
||
ORCHESTRATOR_TASK_ASSIGNED: 'orchestrator:taskAssigned',
|
||
ORCHESTRATOR_TASK_COMPLETED: 'orchestrator:taskCompleted',
|
||
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
|
||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||
|
||
// Teams (agent teams)
|
||
TEAM_CREATED: 'team:created',
|
||
TEAM_UPDATED: 'team:updated',
|
||
TEAM_REMOVED: 'team:removed',
|
||
TEAM_TASK_UPDATED: 'team:taskUpdated',
|
||
|
||
// Transcript
|
||
TRANSCRIPT_COMPLETE: 'transcript:complete',
|
||
TRANSCRIPT_PLAN_MODE: 'transcript:plan_mode',
|
||
TRANSCRIPT_TOOL_START: 'transcript:tool_start',
|
||
TRANSCRIPT_TOOL_END: 'transcript:tool_end',
|
||
|
||
// Clipboard
|
||
CLIPBOARD_WRITE: 'clipboard:write',
|
||
|
||
// Cases
|
||
CASE_CREATED: 'case:created',
|
||
CASE_LINKED: 'case:linked',
|
||
CASE_DELETED: 'case:deleted',
|
||
CASE_RENAMED: 'case:renamed',
|
||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
|
||
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
|
||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||
// Multi-user (admin-only / targeted)
|
||
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
||
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
||
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
|
||
|
||
// Session order (global tab order sync)
|
||
SESSION_ORDER_CHANGED: 'session:orderChanged',
|
||
|
||
// Web tabs (dashboard URLs)
|
||
WEBVIEW_CHANGED: 'webview:changed',
|
||
TAB_LAYOUT_CHANGED: 'tab:layoutChanged',
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Utility Functions
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Get unified coordinates from mouse or touch event.
|
||
* @param {MouseEvent|TouchEvent} e - The event
|
||
* @returns {{ clientX: number, clientY: number }} Coordinates
|
||
*/
|
||
function getEventCoords(e) {
|
||
if (e.touches && e.touches.length > 0) {
|
||
return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY };
|
||
}
|
||
if (e.changedTouches && e.changedTouches.length > 0) {
|
||
return { clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY };
|
||
}
|
||
return { clientX: e.clientX, clientY: e.clientY };
|
||
}
|
||
|
||
// HTML escape utility (shared by NotificationManager, CodemanApp, and ralph-wizard.js)
|
||
const _htmlEscapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||
const _htmlEscapePattern = /[&<>"']/g;
|
||
function escapeHtml(text) {
|
||
if (typeof text !== 'string') return '';
|
||
return text.replace(_htmlEscapePattern, (ch) => _htmlEscapeMap[ch]);
|
||
}
|
||
|
||
/**
|
||
* Human-readable byte size for the partial-history banner (#258).
|
||
*
|
||
* Deliberately coarse: the banner is telling the user roughly how much of a
|
||
* transcript they are looking at, not accounting for bytes. Sub-KB amounts read
|
||
* as "less than 1 KB" rather than an exact count nobody can act on.
|
||
*
|
||
* @param {number} bytes
|
||
* @returns {string}
|
||
*/
|
||
function formatHistoryBytes(bytes) {
|
||
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
|
||
if (n < 1024) return 'less than 1 KB';
|
||
// Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
|
||
// just under 1 MiB and used to print as "1024 KB".
|
||
const kb = Math.round(n / 1024);
|
||
if (kb < 1024) return `${kb} KB`;
|
||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
/**
|
||
* Decide what the partial-history banner should say (#258).
|
||
*
|
||
* PURE so the three states can be tested without a DOM. They exist because one
|
||
* `truncated` boolean could not distinguish messages the user acts on very
|
||
* differently:
|
||
* - recoverable: we tailed for speed and the rest is still retained
|
||
* - atCeiling: the FULL capture itself hit the byte ceiling
|
||
* - exhausted: a full pull was refused as a downgrade, so this is all there is
|
||
*
|
||
* ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
|
||
* return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
|
||
* pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
|
||
* whose transcript lives in the CLI and is scrolled there), so the bytes a tail
|
||
* cut dropped are old repaint frames that `full=1` cannot bring back. Measured
|
||
* on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
|
||
* and the button only ever ended in the downgrade refusal. Nothing to offer,
|
||
* nothing to say. Absent means unknown and keeps the byte-based behaviour.
|
||
*
|
||
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
|
||
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
|
||
* paneHistoryLines?: number|null, historyLinesCut?: boolean}} state
|
||
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
|
||
*/
|
||
function computeHistoryTruncationNotice(state = {}) {
|
||
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
|
||
const historyLines =
|
||
typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
|
||
? Math.max(0, state.paneHistoryLines)
|
||
: null;
|
||
if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
|
||
|
||
const retained = Math.max(0, state.retainedBytes || 0);
|
||
const dropped = Math.max(0, (state.fullSize || 0) - retained);
|
||
const shown = formatHistoryBytes(retained);
|
||
// A full-history capture that was STILL capped is already everything tmux
|
||
// holds, so the remainder is out of reach rather than one request away.
|
||
const atCeiling = state.source === 'mux-full-history' && state.reason === 'capped';
|
||
|
||
if (state.exhausted) {
|
||
return {
|
||
visible: true,
|
||
message: `Showing all ${shown} of retained history. Earlier output is no longer kept for this session.`,
|
||
canLoadMore: false,
|
||
};
|
||
}
|
||
if (atCeiling) {
|
||
return {
|
||
visible: true,
|
||
message: `Showing the most recent ${shown}. Earlier output exceeds the retained history limit and cannot be recovered.`,
|
||
canLoadMore: false,
|
||
};
|
||
}
|
||
// Name what a pull can actually return when the server said: the byte gap
|
||
// counts repaints and redraw bloat, and overstates it many times over.
|
||
// Without that count, a `lines=`-bounded capture is cut by tmux before any
|
||
// bytes are counted, so its fullSize is the window itself and the remainder's
|
||
// size is unknown: say that more exists without inventing an amount. That
|
||
// holds even when `tail` also cut bytes off the window (`dropped`), since tmux
|
||
// still holds more beyond it (`historyLinesCut`, set by the server).
|
||
const remainder =
|
||
dropped > 0 && !state.historyLinesCut ? `${formatHistoryBytes(dropped)} more` : 'Earlier output';
|
||
const more =
|
||
historyLines !== null
|
||
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
|
||
: `${remainder} may still be retained.`;
|
||
return {
|
||
visible: true,
|
||
message: `Showing the most recent ${shown} of this session. ${more}`,
|
||
canLoadMore: true,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Where to land after a rewrite that REPLACES the whole buffer (#259).
|
||
*
|
||
* The backpressure refresh clears the terminal and reloads it from a freshly
|
||
* fetched capture, so an absolute viewportY captured beforehand means nothing
|
||
* afterwards: the line it pointed at may not even exist. Distance from the
|
||
* BOTTOM is the anchor that survives a rewrite, so a reader stays roughly
|
||
* where they were reading.
|
||
*
|
||
* Returns null when the user was following live output, which the caller reads
|
||
* as "scroll to bottom" — the historical behavior, kept for that case.
|
||
*
|
||
* @param {{linesFromBottom?: number, baseY?: number}} input
|
||
* @returns {number|null}
|
||
*/
|
||
function computeRewriteScrollLine(input) {
|
||
const linesFromBottom = input?.linesFromBottom || 0;
|
||
if (!(linesFromBottom > 0)) return null;
|
||
return Math.max(0, (input?.baseY || 0) - linesFromBottom);
|
||
}
|
||
|
||
/**
|
||
* Absolute file paths in agent output, as ONE pattern with two consumers: the
|
||
* xterm link provider (terminal-ui.js) and the response viewer's markdown
|
||
* linkifier (app.js). They used to be able to drift, and a path that is
|
||
* clickable in the terminal but inert in the chat reads as a bug, not a policy.
|
||
*
|
||
* Anchored on a known absolute root (so an ordinary fraction or a date can
|
||
* never match) and terminated by a known extension (so the end of the path is
|
||
* unambiguous — a trailing `)` or `.` after the extension stays out). Longer
|
||
* extensions come first in each family (`tsx|ts`), so the trailing `\b` cannot
|
||
* be satisfied by the shorter branch mid-word. `/etc` is deliberately NOT a
|
||
* root: DEFAULT_BLOCKED_TREES (config/attachment-guard.ts) refuses the whole
|
||
* tree server-side, so every `/etc/...` link was a guaranteed 403 — a link
|
||
* that renders clickable and then dies is worse than plain text.
|
||
*
|
||
* ⚠ Consumers must never share one instance: `lastIndex` is per-object state on
|
||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||
*/
|
||
const FILE_PATH_LINK_PATTERN =
|
||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||
|
||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||
function absoluteFilePathPattern() {
|
||
return new RegExp(FILE_PATH_LINK_PATTERN.source, 'g');
|
||
}
|
||
|
||
/**
|
||
* Extensions the file-preview overlay renders itself. Everything else a link
|
||
* points at goes to the tail/log viewer, which is the right home for a growing
|
||
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
|
||
*
|
||
* Markdown is here too (owner decision 2026-10-13): an agent's `.md` is a
|
||
* document to read, so the preview renders it (its MD pill gives the source)
|
||
* instead of the log viewer streaming it as raw text. Other text (.log, .txt,
|
||
* .json, code) keeps the log viewer, which follows a file still being written.
|
||
*
|
||
* The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||
* (src/attachment-registry.ts, the single source) — they diverged once and an
|
||
* in-workspace `.m4a` opened as binary noise in the log viewer while the same
|
||
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||
*/
|
||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus md markdown').split(' ')
|
||
);
|
||
|
||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||
function previewsInFileViewer(filePath) {
|
||
const ext = String(filePath || '').split('.').pop().toLowerCase();
|
||
return FILE_PREVIEW_EXTENSIONS.has(ext);
|
||
}
|
||
|
||
/**
|
||
* Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
|
||
* echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
|
||
* or a bare `look_montage.png`. Same extension list and body as
|
||
* {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
|
||
* equal).
|
||
*
|
||
* Without a known root to anchor on, the START is what has to be unambiguous:
|
||
* group 1 is the boundary in front of the path (line start, whitespace, a quote,
|
||
* an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
|
||
* a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
|
||
* a URL. The path itself is group 2. Its first character is never `/`: absolute
|
||
* paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
|
||
* token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
|
||
* relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
|
||
* the file, not the punctuation around it.
|
||
*
|
||
* ⚠ Like its sibling, never share an instance (`lastIndex`): use
|
||
* {@link findFilePathLinks}.
|
||
*/
|
||
const RELATIVE_FILE_PATH_LINK_PATTERN =
|
||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||
|
||
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
|
||
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||
|
||
/**
|
||
* Every linkable file path in `text`, sorted by position. Spaced candidates
|
||
* reserve their whole span first, even when unlinkable; rooted, `~/`, relative
|
||
* and folder detectors then skip reserved spans, earlier matches and URLs.
|
||
*
|
||
* The one entry point for both consumers (terminal link provider, response
|
||
* viewer). The viewer carries a leading bold label into the text it passes
|
||
* (spacedFilePathOnLine judges it, as for the same line in the terminal) and
|
||
* reads a text node that follows another inline element as mid-line; paths
|
||
* split across inline elements are still detected per text node.
|
||
*
|
||
* @param {string} text
|
||
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
|
||
* `text`; `folder` marks a folder path (folder grid) rather than a file.
|
||
*/
|
||
function findFilePathLinks(text) {
|
||
const value = String(text || '');
|
||
const found = [];
|
||
const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
|
||
// Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
|
||
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
|
||
// (`file:///home/x.png` is not one of them: its path is the file.)
|
||
const urls = [];
|
||
const webUrlPattern = /\bhttps?:\/\/\S+/gi;
|
||
let match;
|
||
while ((match = webUrlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
|
||
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
|
||
|
||
// A path with spaces is taken whole, or the span it covers links nothing
|
||
// (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it.
|
||
const reserved = [];
|
||
let lineStart = 0;
|
||
for (const line of value.split('\n')) {
|
||
const spaced = spacedFilePathOnLine(line);
|
||
if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) {
|
||
const index = lineStart + spaced.index;
|
||
reserved.push({ index, path: spaced.path });
|
||
if (spaced.link) found.push({ path: spaced.path, index });
|
||
}
|
||
lineStart += line.length + 1;
|
||
}
|
||
const isReserved = (index, length) =>
|
||
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
|
||
|
||
const absolute = absoluteFilePathPattern();
|
||
while ((match = absolute.exec(value)) !== null) {
|
||
if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) {
|
||
found.push({ path: match[1], index: match.index });
|
||
}
|
||
}
|
||
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
|
||
while ((match = relative.exec(value)) !== null) {
|
||
const path = match[2];
|
||
const index = match.index + match[1].length;
|
||
if (overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue;
|
||
found.push({ path, index });
|
||
}
|
||
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
|
||
while ((match = folders.exec(value)) !== null) {
|
||
const path = folderLinkPath(match[2]);
|
||
const index = match.index + match[1].length;
|
||
if (!path || overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue;
|
||
found.push({ path, index, folder: true });
|
||
}
|
||
return found.sort((a, b) => a.index - b.index);
|
||
}
|
||
|
||
/** Labels that explicitly identify a path, for {@link spacedFilePathOnLine}. */
|
||
const SPACED_FILE_PATH_LABEL_PATTERN =
|
||
/^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i;
|
||
|
||
/**
|
||
* A file path WITH SPACES on one line, or null.
|
||
*
|
||
* Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx`
|
||
* linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the
|
||
* wrong targets. A space is only safe inside a path where the line itself bounds
|
||
* the path, so a spaced path is recognised in exactly these shapes:
|
||
*
|
||
* /mnt/c/My Docs/report.docx the whole line is the path
|
||
* - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`)
|
||
* Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN)
|
||
*
|
||
* An absolute path may stand alone or follow a marker. Any label, before an
|
||
* absolute, relative or `~/` path, must be an explicit path label
|
||
* (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic
|
||
* labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative
|
||
* path needs a path label to be taken spaced. One trailing sentence mark is left
|
||
* out, and a leading run of `./` or `../` is not a segment. The candidate must read as ONE path:
|
||
* it ends on a known extension, a rooted one has a directory before its first
|
||
* space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`,
|
||
* and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
|
||
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, is an arrow or has
|
||
* no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and
|
||
* a run of spaces (a column gap) is refused, so two paths on a line are never
|
||
* merged. A relative candidate whose only `/` word is its last is prose after the
|
||
* label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits
|
||
* `Saved to: My Docs/plan.md` as master does. An earlier word that is a lowercase
|
||
* connective or verb (SPACED_PATH_PROSE_WORD: `and`, `in`, `has`, `contains`, ...)
|
||
* or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`),
|
||
* so a name such as `Pros and Cons.md` splits as master does. A command line is
|
||
* refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the
|
||
* first word, any earlier word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`,
|
||
* `/mnt/c/My Docs$ ls a.md`), or any `-x`/`--x` flag word.
|
||
*
|
||
* A candidate that is one path but not a linkable one (an unknown root such as
|
||
* `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last
|
||
* word still holds a `/`: its span links nothing, rather than the fragment after
|
||
* its last space. Otherwise (`/usr/bin/env node build.js`) the line is left to
|
||
* the ordinary detectors.
|
||
*
|
||
* Linear: one bounded prefix match, then string scans.
|
||
*
|
||
* @param {string} line One line, no newline.
|
||
* @returns {{path: string, index: number, link: boolean} | null}
|
||
*/
|
||
function spacedFilePathOnLine(line) {
|
||
const text = String(line || '');
|
||
const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text);
|
||
const index = prefix[0].length;
|
||
const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, '');
|
||
if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null;
|
||
// After a label, only a path label introduces a path; with no label, only an
|
||
// absolute path may stand alone. `Error: /a/b contains c.md` is prose.
|
||
if (prefix[2] ? !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2]) : !path.startsWith('/')) return null;
|
||
if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null;
|
||
// A run of spaces is a column gap (`git status`, `ls -l`), not a name.
|
||
if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://') || path.includes(' ')) return null;
|
||
const words = path.split(' ');
|
||
// A rooted candidate must have a directory before its first space: `/usr/bin/env node a.js`
|
||
// or `/review the changes in src/a.ts` is a command, not a spaced path.
|
||
if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null;
|
||
// A command whose executable sits under a known root (`/opt/homebrew/bin/node a.js`,
|
||
// `/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`, `/home/me/proj/scripts/deploy prod.yml`)
|
||
// is not a path either.
|
||
if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) {
|
||
return null;
|
||
}
|
||
for (const word of words.slice(0, -1)) {
|
||
if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null;
|
||
// A shell prompt that prints its working directory in full (`/home/me/proj$ cat
|
||
// notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is
|
||
// one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$`
|
||
// can end any earlier word, not only the first.
|
||
if (word.endsWith('$')) return null;
|
||
// A word with no letter or digit (a prompt glyph `❯` or `$`, a `✓`) is never
|
||
// part of a file name, apart from a bare `-` (`OneDrive - Contoso`).
|
||
if (word !== '-' && !/[\p{L}\p{N}]/u.test(word)) return null;
|
||
// `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix.
|
||
if (/:\d/.test(word)) return null;
|
||
// An extension anywhere in an earlier word (`a.md,`, `a.ts#L12`) ends a path there.
|
||
if (PATH_TOKEN_HAS_EXTENSION.test(word)) return null;
|
||
// A connective or verb joins a path to prose (`/tmp/out has the logs in run.log`,
|
||
// `Files: src/components and utils/x.ts`), so the candidate is a sentence.
|
||
if (SPACED_PATH_PROSE_WORD.test(word)) return null;
|
||
}
|
||
if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null;
|
||
const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/');
|
||
const segments = path.replace(/^(?:~?\/|(?:\.\.?\/)+)/, '').split('/');
|
||
const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..');
|
||
// A path label vouches that a path follows, not for the words before it: when
|
||
// only the last word holds a `/` (`Wrote: 120 lines to src/foo.ts`), the spaces
|
||
// are prose, so a relative candidate is left to the ordinary detectors.
|
||
const firstSlash = words.findIndex((word) => word.includes('/'));
|
||
if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null;
|
||
// Reserve an unlinkable span only when it still reads as a path to its end;
|
||
// otherwise leave the line to the ordinary detectors.
|
||
if (!link && !words[words.length - 1].includes('/')) return null;
|
||
return { path, index, link };
|
||
}
|
||
|
||
/**
|
||
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
|
||
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
|
||
* trailing slash (`builds/captures/`). Same boundary in front as the relative
|
||
* file pattern (group 1), the candidate itself is group 2 and goes through
|
||
* {@link folderLinkPath}, which decides whether it reads as a folder at all.
|
||
* File links are found first and win any overlap. A folder path ends at `:`,
|
||
* which is also a boundary in front: `PATH=/a/bin:/b/bin` is two candidates, and
|
||
* the `://` lookahead stops at the next colon instead of rescanning the token from
|
||
* every one (quadratic on `a:a:a:…//`).
|
||
*
|
||
* ⚠ Like its siblings, never share an instance (`lastIndex`): use
|
||
* {@link findFilePathLinks}.
|
||
*/
|
||
const FOLDER_PATH_LINK_PATTERN =
|
||
/(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,:\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*)/g;
|
||
|
||
/**
|
||
* A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
|
||
*
|
||
* Sentence punctuation after the path is not part of it. A path ending in `/` is
|
||
* a folder; otherwise only a rooted or `~/` path whose last segment has no
|
||
* extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
|
||
* without a slash at the end is prose, not a folder.
|
||
*/
|
||
function folderLinkPath(candidate) {
|
||
// `$` and `#` too: a shell prompt (`user@host:~/proj$ `, `root@c:/app# `) ends
|
||
// its cwd with one. A folder whose name really ends in one links one short.
|
||
const path = String(candidate || '').replace(/[.,:;!?'"$#]+$/, '');
|
||
if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
|
||
if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
|
||
if (!path.startsWith('/') && !path.startsWith('~/')) return null;
|
||
const last = path.slice(path.lastIndexOf('/') + 1);
|
||
return /^\.?[^.]+$/.test(last) ? path : null;
|
||
}
|
||
|
||
/**
|
||
* Where a clicked path points, as the preview routes need it.
|
||
*
|
||
* Absolute and `~/` paths pass through unchanged — `~` is the home of the
|
||
* session's HOST, which only the server knows (a remote case's home is on the
|
||
* remote machine), so the server expands it. A relative path is resolved
|
||
* against the session's working directory, `.` and `..` collapsed, so a
|
||
* `../other/x.png` ends up outside the workspace and routes like any other
|
||
* external path. Without a working directory it is returned as is.
|
||
*
|
||
* @param {string} filePath
|
||
* @param {string} [workingDir]
|
||
* @returns {string}
|
||
*/
|
||
function resolveLinkedFilePath(filePath, workingDir) {
|
||
const path = String(filePath || '');
|
||
if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
|
||
const parts = [];
|
||
for (const part of `${workingDir}/${path}`.split('/')) {
|
||
if (!part || part === '.') continue;
|
||
if (part === '..') parts.pop();
|
||
else parts.push(part);
|
||
}
|
||
return `/${parts.join('/')}`;
|
||
}
|
||
|
||
/**
|
||
* The directory a RELATIVE path printed for a session is relative TO.
|
||
*
|
||
* `workingDir` is recorded once at create time and never follows a `cd`, so it
|
||
* stops describing where a shell (or an agent that changed directory) really
|
||
* is. `session.cwd` is the pane's live `#{pane_current_path}`, published by the
|
||
* server from its pane read. It only changes which BASE a relative path joins;
|
||
* the result is then routed like any other path, so a target outside the
|
||
* workspace still goes through the attachment routes.
|
||
*
|
||
* Remote and Docker sessions are excluded (the server never sends them a cwd,
|
||
* and this checks again): their local pane runs the ssh / docker-exec CLIENT,
|
||
* so its directory says nothing about the agent. An unknown cwd falls back to
|
||
* `workingDir`.
|
||
*
|
||
* @param {{workingDir?: string, cwd?: string, remote?: unknown, docker?: unknown}} [session]
|
||
* @returns {string|undefined}
|
||
*/
|
||
function relativeResolutionBase(session) {
|
||
if (!session) return undefined;
|
||
const cwd = session.cwd;
|
||
if (!session.remote && !session.docker && typeof cwd === 'string' && cwd.startsWith('/')) return cwd;
|
||
return session.workingDir;
|
||
}
|
||
|
||
/**
|
||
* Extensions the file preview renders as a PAGE, from the sandboxed
|
||
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
|
||
* HTML_VIEW_EXTENSIONS this mirrors; test/file-preview-html-page.test.ts pins
|
||
* the two equal). The terminal sends a click on one to the preview, never to
|
||
* the log viewer, and the preview renders it with a Page pill back to source.
|
||
*/
|
||
const HTML_PAGE_EXTENSIONS = new Set(['html', 'htm']);
|
||
|
||
/** Whether a path names a file the preview renders as a page ({@link HTML_PAGE_EXTENSIONS}). */
|
||
function rendersAsPage(filePath) {
|
||
const ext = String(filePath || '').split('.').pop().toLowerCase();
|
||
return HTML_PAGE_EXTENSIONS.has(ext);
|
||
}
|
||
|
||
|
||
/**
|
||
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
|
||
* string, and a two-way map between that string and terminal cells.
|
||
*
|
||
* One definition, two consumers: the link provider matches its patterns over this
|
||
* text (`registerFilePathLinkProvider`) and touch selection measures words and
|
||
* whole lines with it (`_touchSelectionLogicalLine`). They MUST agree — a link that
|
||
* spans a wrap and a "Line" that stops at the screen edge is the same bug twice.
|
||
*
|
||
* Two kinds of continuation, and handling only the first is not enough:
|
||
*
|
||
* 1. **Soft wrap** — the emulator ran out of columns and flags the next row
|
||
* `isWrapped`. It inserts nothing, so the row's text is joined verbatim.
|
||
* 2. **Hard wrap** — the program wrapped the text itself and emitted a real
|
||
* newline, so nothing is flagged. A row that fills the last column is taken
|
||
* as continuing into the next; that is the only trace a hard wrap leaves.
|
||
*
|
||
* ⚠️ A hard-wrapped continuation may carry the program's own INDENT, and joining
|
||
* that verbatim puts whitespace in the middle of the token being stitched. That is
|
||
* why an agent's numbered list —
|
||
*
|
||
* 1. https://github.com/users/someone/packages/container/p
|
||
* ackage/thing
|
||
*
|
||
* — opened only `…/container/p`: the URL pattern stops at the space the indent
|
||
* contributed. So the leading whitespace of a HARD continuation is dropped, and
|
||
* `colStart` on that segment records how much, keeping the cell mapping exact. A
|
||
* soft continuation keeps its leading whitespace, since the terminal never adds
|
||
* any and it is therefore real content.
|
||
*
|
||
* ⚠️ Only the final row is trimmed. Continuation rows are read UNTRIMMED so each
|
||
* contributes exactly `cols` cells; trimming one would shift every later offset.
|
||
*
|
||
* The row span is bounded by `maxRows` (12 by default): this runs on every hover,
|
||
* and a screenful of full-width output would otherwise re-scan the viewport each
|
||
* time.
|
||
*
|
||
* @param {{getLine: (row: number) => any, length: number}} buffer xterm buffer.
|
||
* @param {number} row 0-based ABSOLUTE buffer row to expand around.
|
||
* @param {number} cols Terminal width.
|
||
* @param {number} [maxRows] Row-span bound.
|
||
* @returns {{startRow: number, endRow: number, text: string,
|
||
* offsetToCell: (offset: number) => {row: number, col: number},
|
||
* cellToOffset: (row: number, col: number) => number} | null}
|
||
* 0-based rows and columns throughout; null when the row does not exist.
|
||
*/
|
||
function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||
if (!buffer || typeof buffer.getLine !== 'function') return null;
|
||
const width = Math.max(1, cols || 1);
|
||
const bound = Math.max(1, maxRows || 12);
|
||
const lineAt = (r) => (r >= 0 ? buffer.getLine(r) : undefined);
|
||
if (!lineAt(row)) return null;
|
||
|
||
const continuesPrevious = (r) => {
|
||
if (r <= 0) return false;
|
||
if (lineAt(r)?.isWrapped) return true;
|
||
// A divider row never continues the row above, even when that row reaches
|
||
// the right edge: a URL ending at the edge must not absorb `────` below it.
|
||
if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(lineAt(r)?.translateToString(true) || '')) return false;
|
||
const prev = lineAt(r - 1);
|
||
const prevText = prev?.translateToString(true) || '';
|
||
// A full-width table divider is a row boundary, not a hard-wrapped line.
|
||
// Treating it as a continuation glues the divider onto the row below and
|
||
// hides that row's wrapped URL from the link provider.
|
||
if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(prevText)) return false;
|
||
return prevText.length >= width;
|
||
};
|
||
|
||
let startRow = row;
|
||
while (startRow > 0 && row - startRow < bound && continuesPrevious(startRow)) startRow--;
|
||
let endRow = row;
|
||
const length = Number.isFinite(buffer.length) ? buffer.length : endRow + 1;
|
||
while (endRow + 1 < length && endRow - startRow < bound && continuesPrevious(endRow + 1)) endRow++;
|
||
|
||
const segments = [];
|
||
let text = '';
|
||
for (let r = startRow; r <= endRow; r++) {
|
||
const line = lineAt(r);
|
||
if (!line) break;
|
||
let rowText = line.translateToString(r === endRow) || '';
|
||
let colStart = 0;
|
||
if (r > startRow && !line.isWrapped) {
|
||
const indent = rowText.length - rowText.replace(/^\s+/, '').length;
|
||
colStart = indent;
|
||
rowText = rowText.slice(indent);
|
||
}
|
||
segments.push({ row: r, textStart: text.length, colStart, length: rowText.length });
|
||
text += rowText;
|
||
}
|
||
|
||
const offsetToCell = (offset) => {
|
||
for (let i = segments.length - 1; i >= 0; i--) {
|
||
const seg = segments[i];
|
||
if (offset >= seg.textStart || i === 0) {
|
||
return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
|
||
}
|
||
}
|
||
return { row: startRow, col: offset };
|
||
};
|
||
|
||
const cellToOffset = (targetRow, targetCol) => {
|
||
for (const seg of segments) {
|
||
if (seg.row !== targetRow) continue;
|
||
return seg.textStart + Math.max(0, targetCol - seg.colStart);
|
||
}
|
||
return -1;
|
||
};
|
||
|
||
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||
}
|
||
|
||
/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
|
||
const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
|
||
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
|
||
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
|
||
/**
|
||
* Lowercase connectives and verbs that put a spaced candidate in a sentence, for
|
||
* {@link spacedFilePathOnLine}. `at` stays out (`Screenshot 2026-10-24 at 10.00.00.png`)
|
||
* and so does `of` (`Copy of report.docx`); a name using a listed word
|
||
* (`Pros and Cons.md`) splits as it did before spaced paths were linked.
|
||
*/
|
||
const SPACED_PATH_PROSE_WORD =
|
||
/^(?:and|or|to|in|into|from|the|is|are|was|were|has|have|had|contains|holds|see|then|vs|via|as)$/;
|
||
/** A rooted path's start (`/home/`, `/mnt/`, …), for {@link spacedFilePathOnLine}. */
|
||
const SPACED_PATH_ROOT = new RegExp(`^\\/(?:${FILE_LINK_ROOT_GROUP})\\/`);
|
||
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
|
||
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
|
||
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
|
||
const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0];
|
||
|
||
/**
|
||
* Whether the line `nextText` carries on a file path `prevText` broke off.
|
||
*
|
||
* Answers `'join'` when the path ends (reaches a known extension) on the next
|
||
* line, `'chain'` when the next line is nothing but more of the path (a path
|
||
* spanning three or more lines; only worth keeping once a later line ends it),
|
||
* and `null` otherwise. The broken-off token must already look like a path in
|
||
* progress: it contains a `/`, ends on a path character and does not yet end
|
||
* on an extension. A next line that opens a NEW path is never glued on: a rooted
|
||
* one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same
|
||
* top directory the broken token did (the next entry of a listing).
|
||
*/
|
||
function pathContinuation(prevText, nextText) {
|
||
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
|
||
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
||
const next = String(nextText || '').replace(/^\s+/, '');
|
||
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
|
||
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
|
||
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
|
||
// followed by `src/index.ts` is two entries, not one path broken after `src/components`.
|
||
// A wrapped path continues INSIDE a name: its next row does not open on the same top directory.
|
||
if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null;
|
||
if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
|
||
return head === next.replace(/\s+$/, '') ? 'chain' : null;
|
||
}
|
||
|
||
/**
|
||
* {@link terminalLogicalLine}, extended across the line breaks a program puts
|
||
* INSIDE a file path. Used by the link provider only; touch selection keeps the
|
||
* plain logical line.
|
||
*
|
||
* Claude Code wraps its tool output itself, a few columns short of the terminal
|
||
* edge, so neither continuation signal of terminalLogicalLine fires (no
|
||
* `isWrapped`, the row does not reach the last column) and a long path was only
|
||
* linked up to the break:
|
||
*
|
||
* ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
|
||
* 026-10-10/ghost1b_gameplay.png err=0 …
|
||
*
|
||
* A following logical line is glued on (its indent dropped, cell mapping kept
|
||
* exact) when {@link pathContinuation} says it carries the path on and the row it
|
||
* broke off is at least as long as the row it continues on. Bounded by the same
|
||
* `maxRows` as the logical line.
|
||
*
|
||
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
|
||
*/
|
||
function terminalPathLine(buffer, row, cols, maxRows) {
|
||
const base = terminalLogicalLine(buffer, row, cols, maxRows);
|
||
if (!base) return null;
|
||
const bound = Math.max(1, maxRows || 12);
|
||
const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
|
||
const rowsOf = (line) => line.endRow - line.startRow + 1;
|
||
// A program breaking a long token fills the row to its wrap width, and no
|
||
// later row of that block is wider. So the row a path broke off is at least as
|
||
// long as the row it continues on — which keeps `cd /mnt/foo` from being glued
|
||
// to a longer ` file.png written` below it.
|
||
const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
|
||
const continues = (prev, next, prevText) =>
|
||
rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
|
||
|
||
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
|
||
const extend = (first) => {
|
||
const parts = [first];
|
||
let text = first.text;
|
||
let rows = rowsOf(first);
|
||
let confirmed = 1;
|
||
for (;;) {
|
||
const next = lineAt(parts[parts.length - 1].endRow + 1);
|
||
if (!next || rows + rowsOf(next) > bound) break;
|
||
const kind = continues(parts[parts.length - 1], next, text);
|
||
if (!kind) break;
|
||
parts.push(next);
|
||
text += next.text.replace(/^\s+/, '');
|
||
rows += rowsOf(next);
|
||
if (kind === 'join') confirmed = parts.length;
|
||
}
|
||
return parts.slice(0, confirmed);
|
||
};
|
||
|
||
// The hovered row may be the second or third line of a broken path: walk back
|
||
// to where it could start, then let the forward pass decide what really joins.
|
||
let first = base;
|
||
let rows = rowsOf(base);
|
||
while (first.startRow > 0) {
|
||
const prev = lineAt(first.startRow - 1);
|
||
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
|
||
first = prev;
|
||
rows += rowsOf(prev);
|
||
}
|
||
let parts = extend(first);
|
||
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
|
||
if (parts.length === 1) return parts[0];
|
||
|
||
const segments = [];
|
||
let text = '';
|
||
for (const [i, part] of parts.entries()) {
|
||
const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
|
||
segments.push({ part, textStart: text.length, skip });
|
||
text += part.text.slice(skip);
|
||
}
|
||
const offsetToCell = (offset) => {
|
||
for (let i = segments.length - 1; i >= 0; i--) {
|
||
const seg = segments[i];
|
||
if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
|
||
}
|
||
return segments[0].part.offsetToCell(offset);
|
||
};
|
||
const cellToOffset = (targetRow, targetCol) => {
|
||
for (const seg of segments) {
|
||
if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
|
||
const offset = seg.part.cellToOffset(targetRow, targetCol);
|
||
return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
|
||
}
|
||
return -1;
|
||
};
|
||
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
|
||
}
|
||
|
||
/**
|
||
* Web URLs in terminal text, with the trimming the link provider applies.
|
||
*
|
||
* A single `&` is part of the URL (query separator); `&&` is the shell operator
|
||
* and ends it. `)` is kept INSIDE a URL when it closes a `(` that is also inside
|
||
* it (`…/Team-Space(QA)Archive/…` used to link only as far as `(QA`), and an
|
||
* unmatched `)` ends the URL, which keeps the closing paren of `(https://…)` and
|
||
* `[text](https://…)` out of the link. Trailing sentence punctuation is trimmed.
|
||
*
|
||
* @param {string} text
|
||
* @returns {Array<{url: string, startIndex: number, endIndex: number, rawEnd: number}>}
|
||
* `endIndex` is exclusive; `rawEnd` is where the match ran to before the
|
||
* paren/punctuation trim (the wrap-continuation test needs it).
|
||
*/
|
||
function findTerminalUrls(text) {
|
||
// Named `urlPattern` on purpose: test/link-provider-regex.test.ts extracts it
|
||
// by name to prove it stays linear-time on hostile lines.
|
||
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&\]\x00-\x1f]|&(?!&))+/g;
|
||
const urls = [];
|
||
let match;
|
||
while ((match = urlPattern.exec(String(text || ''))) !== null) {
|
||
const raw = match[0];
|
||
let end = raw.length;
|
||
let depth = 0;
|
||
for (let i = 0; i < raw.length; i++) {
|
||
if (raw[i] === '(') depth++;
|
||
else if (raw[i] === ')') {
|
||
if (depth === 0) {
|
||
end = i;
|
||
break;
|
||
}
|
||
depth--;
|
||
}
|
||
}
|
||
const url = raw.slice(0, end).replace(/[.,;:!?&]+$/, '');
|
||
if (!/:\/\/[^/]/.test(url)) continue;
|
||
urls.push({ url, startIndex: match.index, endIndex: match.index + url.length, rawEnd: match.index + raw.length });
|
||
}
|
||
return urls;
|
||
}
|
||
|
||
/**
|
||
* The row span a URL covers when the CLI wrapped it with its OWN newlines at a
|
||
* point short of the terminal width.
|
||
*
|
||
* {@link terminalLogicalLine} joins soft wraps and full-width hard wraps. A CLI
|
||
* that word-wraps inside a table cell or a paragraph (codex's markdown tables,
|
||
* for one) breaks a long URL at `/`, `?`, `-` … well before the right edge and
|
||
* indents the rest to the cell's column, so neither rule fires and each row
|
||
* linked a fragment. A row continues the URL above it only when ALL hold:
|
||
* - the URL fragment ends on a character a wrapper breaks after
|
||
* (`/ ? & = - _ % # + ~`); a following table column may be excluded;
|
||
* - the next row is indented, but no further right than the column the URL
|
||
* started in (a table cell or list item continuation, not unrelated text),
|
||
* and no column gap lies between its indent and the URL on the first row
|
||
* (that would be the next record of a table);
|
||
* - that row's first token looks like URL material (contains one of
|
||
* `% & = / ? . - _`), is not a bare number/version or list marker,
|
||
* and is not itself a new `scheme://`, a shell prompt (`user@host:`),
|
||
* a home path (`~/`), an email address, an HTTP status line, a label
|
||
* ending in `:`, a measurement or clock time (`45%`, `12.3ms`,
|
||
* `10:00:00`), or a `name=value` field under a URL ending in `/`;
|
||
* without `% & = / ?`, the token must finish its cell (row end or a
|
||
* gap of at least two spaces) and must not be code with a call paren
|
||
* or a word ending a sentence (`Done.`).
|
||
* The last rule is what stops prose such as "…/files/" + "then run it" from
|
||
* being glued into one link.
|
||
*
|
||
* Bounded by `maxRows`, like the logical line. Pure: reads rows only.
|
||
*
|
||
* @param {{getLine: (row: number) => any, length: number}} buffer
|
||
* @param {number} row 0-based absolute row being examined.
|
||
* @param {number} cols Terminal width.
|
||
* @param {number} [maxRows]
|
||
* @returns {{startRow: number, endRow: number, firstRowUrlEnd?: number} | null}
|
||
* `firstRowUrlEnd` is where the URL's cell ends on the first row when
|
||
* other table columns follow it and that row does not soft-wrap; null
|
||
* means the row is not part of a URL continued this way.
|
||
*/
|
||
function terminalUrlContinuationExtent(buffer, row, cols, maxRows) {
|
||
const bound = Math.max(1, maxRows || 12);
|
||
const here = terminalLogicalLine(buffer, row, cols, bound);
|
||
if (!here) return null;
|
||
|
||
const chainFrom = (start) => {
|
||
const first = terminalLogicalLine(buffer, start, cols, bound);
|
||
if (!first || first.startRow !== start) return null;
|
||
let text = first.text.replace(/\s+$/, '');
|
||
let endRow = first.endRow;
|
||
let urlStartCol = -1;
|
||
let firstRowUrlEnd = null;
|
||
let extended = false;
|
||
while (endRow - start + 1 < bound) {
|
||
const urls = findTerminalUrls(text);
|
||
const last = urls[urls.length - 1];
|
||
// Only on a one-row first line: firstRowUrlEnd cuts the first PHYSICAL
|
||
// row (terminalLinkLine), so a column sitting on the soft-wrapped part of
|
||
// the URL's row would stay in the text and take the next row with it.
|
||
const trailingColumns =
|
||
first.startRow === first.endRow &&
|
||
endRow === first.endRow &&
|
||
last &&
|
||
last.rawEnd < text.length &&
|
||
/^ {2,}\S/.test(text.slice(last.rawEnd));
|
||
const urlText = trailingColumns ? text.slice(0, last.rawEnd) : text;
|
||
if (!last || last.rawEnd !== urlText.length || !/[/?&=\-_%#+~]$/.test(urlText)) break;
|
||
if (urlStartCol < 0) urlStartCol = first.offsetToCell(last.startIndex).col;
|
||
const next = buffer.getLine(endRow + 1);
|
||
if (!next || next.isWrapped) break;
|
||
const nextText = next.translateToString(true) || '';
|
||
const indent = nextText.length - nextText.replace(/^\s+/, '').length;
|
||
const token = nextText.slice(indent).split(/\s/, 1)[0];
|
||
// A hard continuation is indented (a table cell, a list item, Claude's
|
||
// two-space paragraph indent); a column-0 row is a new line of output.
|
||
if (!token || !indent || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent)))
|
||
break;
|
||
// When other table columns follow the URL, the next row must align with
|
||
// the start of its cell. A new numbered row must not become URL text.
|
||
if (
|
||
trailingColumns &&
|
||
(!indent ||
|
||
first.text[indent - 1] !== ' ' ||
|
||
first.text[indent] === ' ' ||
|
||
/ {2,}/.test(first.text.slice(indent, last.startIndex)))
|
||
)
|
||
break;
|
||
// The next row must start inside the URL's own cell: a column gap between
|
||
// its indent and the URL on the first row means it opens an earlier
|
||
// column (the next record of a table), not the rest of the URL.
|
||
if (/ {2,}/.test(first.text.slice(indent, last.startIndex).trimStart())) break;
|
||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break;
|
||
// A shell prompt (`user@host:~/dir$`), a home path and an HTTP status line
|
||
// are never the rest of a URL, however much URL punctuation they carry.
|
||
if (/^[\w.-]+@[\w.-]+:|^~\/|^HTTP\/\d/i.test(token)) break;
|
||
// Nor is a token that is a whole email address.
|
||
if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break;
|
||
if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break;
|
||
// A label ends with a colon (`Failure/Error:`, `prometheus.io/scrape:`);
|
||
// the rest of a URL does not.
|
||
if (token.endsWith(':')) break;
|
||
// After a `/`, a `name=value` token is the next assignment or log field
|
||
// (`API_KEY=...`, `level=info`), not the URL: a query starts with `?`.
|
||
if (urlText.endsWith('/') && /^[A-Za-z_][\w.-]*=/.test(token)) break;
|
||
// Nor is a measurement or a clock time: `45%` and `45%|####` progress,
|
||
// `12.3MB/45.0MB`, `12.3ms`, `10:00:00` (`%7B` stays percent-encoding).
|
||
if (/^\d+(?:\.\d+)?(?:%(?![0-9A-Fa-f]{2})|(?:ms|s|[kKMGT]i?B)(?![\w-]))|^\d{1,2}:\d{2}/.test(token)) break;
|
||
// Hyphens, underscores and dots also occur in prose. Without stronger
|
||
// URL punctuation, the token must finish its cell, not introduce a sentence.
|
||
const rest = nextText.slice(indent + token.length);
|
||
if (!/[%&=/?]/.test(token)) {
|
||
// Code (`client.get()`, `expect(x).toBe(y)`) is not a URL piece, and a
|
||
// word that ends a sentence (`ready.`, `Done.`) is prose. A wiki title
|
||
// (`Mercury_(planet)`) and a file name before its period
|
||
// (`install.html.`) still continue the URL.
|
||
if (/[^_]\(/.test(token)) break;
|
||
if (/[.,;!]$/.test(token) && !/\.[A-Za-z][A-Za-z0-9]{1,4}[.,;!]+$/.test(token)) break;
|
||
if (rest.trim() !== '' && !/^ {2,}/.test(rest)) break;
|
||
}
|
||
const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound);
|
||
if (!nextLogical) break;
|
||
if (trailingColumns) firstRowUrlEnd = last.rawEnd;
|
||
text = urlText + nextLogical.text.slice(indent).replace(/\s+$/, '');
|
||
endRow = nextLogical.endRow;
|
||
extended = true;
|
||
}
|
||
return { startRow: start, endRow, extended, firstRowUrlEnd };
|
||
};
|
||
|
||
// Walk up through earlier logical lines: the nearest one whose URL chain
|
||
// reaches this row owns it.
|
||
let start = here.startRow;
|
||
while (start >= 0 && here.startRow - start < bound) {
|
||
const chain = chainFrom(start);
|
||
if (chain && chain.extended && chain.endRow >= here.startRow) {
|
||
return {
|
||
startRow: chain.startRow,
|
||
endRow: chain.endRow,
|
||
...(chain.firstRowUrlEnd === null ? {} : { firstRowUrlEnd: chain.firstRowUrlEnd }),
|
||
};
|
||
}
|
||
if (start === 0) break;
|
||
const above = terminalLogicalLine(buffer, start - 1, cols, bound);
|
||
if (!above) break;
|
||
start = above.startRow;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The text the link provider scans for `row`: {@link terminalPathLine}, widened
|
||
* to the rows of a URL the CLI wrapped short of the edge
|
||
* ({@link terminalUrlContinuationExtent}). Same shape as terminalPathLine.
|
||
*
|
||
* Over a widened span every row is read trimmed (except one a soft wrap
|
||
* continues from, which fills the width), a HARD continuation's indent is
|
||
* dropped (the cell mapping keeps the cells), and the first row stops at the
|
||
* URL's cell when other table columns follow it, so those columns never sit
|
||
* between the URL's pieces. Those columns still link: they are moved after the
|
||
* stitched rows as a segment of their own, mapped back to their cells.
|
||
*
|
||
* @returns Same shape as {@link terminalPathLine}; null when the row does not exist.
|
||
*/
|
||
function terminalLinkLine(buffer, row, cols, maxRows) {
|
||
const base = terminalPathLine(buffer, row, cols, maxRows);
|
||
if (!base) return null;
|
||
const extent = terminalUrlContinuationExtent(buffer, row, cols, maxRows);
|
||
if (!extent || (extent.startRow >= base.startRow && extent.endRow <= base.endRow)) return base;
|
||
const startRow = Math.min(extent.startRow, base.startRow);
|
||
const endRow = Math.max(extent.endRow, base.endRow);
|
||
const segments = [];
|
||
let text = '';
|
||
let columnsAfterUrl = '';
|
||
for (let r = startRow; r <= endRow; r++) {
|
||
const line = buffer.getLine(r);
|
||
if (!line) break;
|
||
const next = r < endRow ? buffer.getLine(r + 1) : null;
|
||
let rowText = line.translateToString(!(next && next.isWrapped)) || '';
|
||
if (r === extent.startRow && extent.firstRowUrlEnd != null) {
|
||
columnsAfterUrl = rowText.slice(extent.firstRowUrlEnd);
|
||
rowText = rowText.slice(0, extent.firstRowUrlEnd);
|
||
}
|
||
let colStart = 0;
|
||
if (r > startRow && !line.isWrapped) {
|
||
colStart = rowText.length - rowText.replace(/^\s+/, '').length;
|
||
rowText = rowText.slice(colStart);
|
||
}
|
||
segments.push({ row: r, textStart: text.length, colStart });
|
||
text += rowText;
|
||
}
|
||
const lastRow = segments[segments.length - 1].row;
|
||
// The columns cut off the URL's first row (a path or date column of a table)
|
||
// are scanned after the stitched rows, so they keep their links on that row.
|
||
// They open with the cell gap, which keeps them apart from the URL; the one
|
||
// space before them belongs to the last row, so an end offset there still
|
||
// maps onto that row and never onto their first cell.
|
||
if (columnsAfterUrl.trim()) {
|
||
text += ' ';
|
||
segments.push({ row: extent.startRow, textStart: text.length, colStart: extent.firstRowUrlEnd });
|
||
text += columnsAfterUrl;
|
||
}
|
||
const offsetToCell = (offset) => {
|
||
for (let i = segments.length - 1; i >= 0; i--) {
|
||
const seg = segments[i];
|
||
if (offset >= seg.textStart || i === 0) return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
|
||
}
|
||
return { row: startRow, col: offset };
|
||
};
|
||
// The cut row has two segments: the one starting at or left of the cell owns it.
|
||
const cellToOffset = (targetRow, targetCol) => {
|
||
let seg = null;
|
||
for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s;
|
||
return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1;
|
||
};
|
||
return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset };
|
||
}
|
||
|
||
/**
|
||
* Absolute file paths the CLI word-wrapped INSIDE a table cell (codex markdown
|
||
* tables), returned as one link per path with a segment per row.
|
||
*
|
||
* Before screenshot (/tmp/x/docs/review/ After screenshot (/tmp/x/review/BLDMESH-
|
||
* BLDMESH-243/before-overview.png) 243/after-overview.png)
|
||
*
|
||
* Neither fragment matches FILE_PATH_LINK_PATTERN alone (the first has no
|
||
* extension, the second no absolute root), and a linear stitch of the rows
|
||
* cannot serve a two-column table: each column's continuation sits in the same
|
||
* next row. So each fragment is followed DOWN ITS OWN CELL: the cell's left
|
||
* edge is where its text starts after the previous ≥2-space column gap, and the
|
||
* continuation is the token starting at exactly that column on the next row.
|
||
* A fragment qualifies only when it is the last thing in its cell on that row
|
||
* (row end or a ≥2-space gap follows) and ends on `/` or `-`, the only places
|
||
* a wrapper breaks a path; a cell that ends inside a name (`/home/u/proj` over
|
||
* `config.yaml`) is a whole value, never the first piece of the next row's. A
|
||
* continuation row must be blank to the left of the cell (a wrapper wraps every
|
||
* column of one table row together), unless a path in a left column continued
|
||
* on it too: a label there (`Entry`, `│ Log │`) is the next record. Every
|
||
* continuation must pass pathContinuation and its cell must be no wider than the
|
||
* preceding cell's text, so independent listing rows and longer prose never
|
||
* displace links.
|
||
*
|
||
* Pure: reads rows only. Cells are taken one per string character (xterm rows
|
||
* with wide glyphs would shift columns; such rows simply fail the alignment).
|
||
*
|
||
* @param {{getLine: (row: number) => any, length: number}} buffer
|
||
* @param {number} row 0-based absolute row being examined.
|
||
* @param {number} [maxRows] Bound on rows one path may span.
|
||
* @returns {Array<{path: string, segments: Array<{row: number, startCol: number, endCol: number}>}>}
|
||
* Paths with a segment on `row`; `endCol` is exclusive, columns 0-based.
|
||
*/
|
||
function terminalCellWrappedPaths(buffer, row, maxRows) {
|
||
const bound = Math.max(2, maxRows || 4);
|
||
const rootPattern = new RegExp(`\\/(?:${FILE_LINK_ROOT_GROUP})\\/`, 'g');
|
||
// Box-drawing verticals are cell borders (Claude Code draws tables with them).
|
||
const isPathChar = (ch) => !!ch && !/[\s"'<>|;&\x00-\x1f│┃║]/.test(ch);
|
||
const rowText = (r) => {
|
||
const line = buffer.getLine(r);
|
||
return line ? (line.translateToString(true) || '').replace(/\s+$/, '') : null;
|
||
};
|
||
// A fragment is the last text in its cell on this row when the row ends, a
|
||
// column gap (≥2 spaces) follows it, or a cell border does.
|
||
const endsCell = (text, end) => end >= text.length || /^(?: {2,}| *[│┃║|])/.test(text.slice(end));
|
||
|
||
const fromStart = (startRow) => {
|
||
const found = [];
|
||
const text = rowText(startRow);
|
||
if (!text) return found;
|
||
rootPattern.lastIndex = 0;
|
||
let m;
|
||
while ((m = rootPattern.exec(text)) !== null) {
|
||
const start = m.index;
|
||
if (start > 0 && !/[\s(\[{"'`<]/.test(text[start - 1])) continue;
|
||
let end = start;
|
||
while (end < text.length && isPathChar(text[end])) end++;
|
||
rootPattern.lastIndex = Math.max(end, start + 1);
|
||
if (!endsCell(text, end)) continue;
|
||
// Already a complete path on its own row: the ordinary detector owns it.
|
||
const alone = absoluteFilePathPattern().exec(text.slice(start, end));
|
||
if (alone && alone.index === 0 && alone[0].length === end - start) continue;
|
||
// The cell's left edge: just past the nearest ≥2-space gap or cell border
|
||
// (and the padding after it) before the path.
|
||
const before = text.slice(0, start);
|
||
let cellCol = before.length - before.replace(/^\s+/, '').length;
|
||
const edge = /(?: {2,}|[│┃║|] *)(?=\S)/g;
|
||
let e;
|
||
const upToPath = text.slice(0, start + 1);
|
||
while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length;
|
||
|
||
let joined = text.slice(start, end);
|
||
let previousCellLength = end - cellCol;
|
||
const segments = [{ row: startRow, startCol: start, endCol: end }];
|
||
for (let r = startRow + 1; r < startRow + bound; r++) {
|
||
const line = buffer.getLine(r);
|
||
if (!line || line.isWrapped) break;
|
||
const next = rowText(r);
|
||
if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break;
|
||
let tokenEnd = cellCol;
|
||
while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++;
|
||
const token = next.slice(cellCol, tokenEnd);
|
||
// Include prose after the token in this cell, but not another column.
|
||
const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd();
|
||
// A wrapper breaks a path after `/` or `-`; a fragment that ends inside a
|
||
// name (`/home/u/proj` over `config.yaml`) is a whole value, not a piece.
|
||
// codex wraps table cells on Unicode line-break opportunities plus a
|
||
// hyphen splitter, which break after `/` and `-` but never at `_` (UAX #14
|
||
// class AL).
|
||
if (!/[/-]$/.test(joined)) break;
|
||
// A wrapper wraps every column of one table row together, so a row that
|
||
// carries a cell's continuation is blank to its left unless a path in a
|
||
// left column continued on that row too. A label there (`Entry`,
|
||
// `│ Log │`) opens the next record, even under a directory ending in `/`
|
||
// (`Workspace /home/u/proj/app/` over `Entry main.py`). A left column of
|
||
// wrapped prose (a `Description | Path` table) fills that row as well, so
|
||
// the path beside it is not joined and keeps its per-row fragment links:
|
||
// the safe direction.
|
||
// Known trade-off: with nothing to the left, a directory written with its
|
||
// trailing slash over a relative path in the same column (`/home/u/proj/`
|
||
// over `index.ts`) still joins, since it reads exactly like a wrap at `/`.
|
||
if (
|
||
/[^\s│┃║|]/.test(next.slice(0, cellCol)) &&
|
||
!found.some((hit) => hit.segments.some((seg) => seg.row === r && seg.endCol <= cellCol))
|
||
) {
|
||
break;
|
||
}
|
||
if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break;
|
||
const candidate = joined + token;
|
||
const match = absoluteFilePathPattern().exec(candidate);
|
||
if (match && match.index === 0 && match[0].length > joined.length) {
|
||
segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length });
|
||
found.push({ path: match[0], segments });
|
||
break;
|
||
}
|
||
if (!endsCell(next, tokenEnd)) break;
|
||
joined = candidate;
|
||
previousCellLength = cellText.length;
|
||
segments.push({ row: r, startCol: cellCol, endCol: tokenEnd });
|
||
}
|
||
}
|
||
return found;
|
||
};
|
||
|
||
const results = [];
|
||
for (let s = row; s >= 0 && row - s < bound; s--) {
|
||
for (const hit of fromStart(s)) {
|
||
if (hit.segments.some((seg) => seg.row === row)) results.push(hit);
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// Desktop-only, same reasoning and same threshold as HOME_SESSIONS_MIN_WIDTH
|
||
// (home-sessions.js): two 240px min-width panes plus the divider need ~486px,
|
||
// which a phone or narrow tablet cannot give them, and the divider has no
|
||
// touch handlers. A dedicated constant rather than reusing
|
||
// HOME_SESSIONS_MIN_WIDTH directly — that name lives in home-sessions.js,
|
||
// which loads AFTER this file (load order 12.56 vs 7.5), so referencing it
|
||
// from module-evaluation-time code here would be a ReferenceError.
|
||
const SPLIT_PANE_MIN_WIDTH = 1180;
|
||
|
||
function clampDividerPercent(rawPercent, min = 20, max = 80) {
|
||
if (rawPercent < min) return min;
|
||
if (rawPercent > max) return max;
|
||
return rawPercent;
|
||
}
|
||
|
||
function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds) {
|
||
const result = [];
|
||
for (const id of sessionOrder) {
|
||
if (id === excludeId) continue;
|
||
// A detached (popped-out) session's own window owns its PTY size (see
|
||
// sendResize's detachedElsewhere guard in terminal-ui.js;
|
||
// TerminalTile._sendResize() stands aside the same way), so Pane B could
|
||
// only show it at a size it cannot set.
|
||
if (detachedIds?.has?.(id)) continue;
|
||
const session = sessions.get(id);
|
||
if (!session) continue;
|
||
// A session with no PTY attached (exited CLI, a crash-looped session
|
||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||
// reading its tmux pane, and the split never does selectSession()'s
|
||
// re-attach POST: Pane B would open a healthy-looking socket onto a pane
|
||
// that nothing feeds and nothing reads.
|
||
if (session.pid === null) continue;
|
||
result.push({ id, label: session.name || 'Session' });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ── Tile grid (tile-grid.js) ───────────────────────────────────────────────
|
||
//
|
||
// Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md):
|
||
// 1 to TILE_GRID_MAX live sessions side by side, each in its own TerminalTile.
|
||
// Desktop only, behind the same 1180px gate as the split pane.
|
||
|
||
/**
|
||
* Hard cap on tiles in one grid: the ONE place it is set (owner decision 7 in
|
||
* docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
|
||
* the headless frame bar and is untested on hardware. Everything that limits
|
||
* the grid reads this, and the layout table still covers up to TILE_LAYOUT_MAX,
|
||
* so raising the cap is this one line.
|
||
*/
|
||
const TILE_GRID_MAX = 6;
|
||
/** The largest count the layout table covers (3x3). Never a cap by itself. */
|
||
const TILE_LAYOUT_MAX = 9;
|
||
// The smallest tile worth showing: about 60 columns and a dozen rows at the
|
||
// default tile font. Bounds how many tiles a window can hold.
|
||
const TILE_MIN_W = 480;
|
||
const TILE_MIN_H = 240;
|
||
// Three tiles go side by side (3x1) only when each still gets ~600px;
|
||
// otherwise they take three cells of a 2x2.
|
||
const TILE_GRID_WIDE_3X1 = 1800;
|
||
// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: a grid of DOM
|
||
// renderers at 50k lines each is a real memory cost, and a tile's load is a
|
||
// bounded 1 MiB window anyway, so more scrollback only fills with live output.
|
||
const TILE_SCROLLBACK = 10000;
|
||
// Tiles have their own per-device font size (a tile is a fraction of the screen).
|
||
const TILE_FONT_SIZE_DEFAULT = 13;
|
||
// What the page's SSE filter names while tiles own the terminal: a value no
|
||
// session id takes (ids are UUIDs), so the server, whose filter gates only
|
||
// session:terminal batches, sends none. The tiles carry their own output over
|
||
// their own sockets, and the parked main terminal only parsed those frames to
|
||
// drop them (16 to 18 a second for one busy shell). Every other event still
|
||
// arrives (test/sse-tile-grid-filter.test.ts pins the server's side of this).
|
||
const TILE_GRID_SSE_FILTER = 'tile-grid';
|
||
|
||
/**
|
||
* Columns and rows for `count` tiles, by count (the spec's table), and whether
|
||
* that layout gives every cell at least the minimum tile size (TILE_MIN_W x
|
||
* TILE_MIN_H) in a grid area of `width` x `height` px.
|
||
*
|
||
* @param {{count: number, width?: number, height?: number}} p
|
||
* @returns {{cols: number, rows: number, fits: boolean}}
|
||
*/
|
||
function computeTileLayout({ count, width = Infinity, height = Infinity }) {
|
||
const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX);
|
||
let cols;
|
||
let rows;
|
||
if (n === 0) return { cols: 0, rows: 0, fits: true };
|
||
if (n === 1) { cols = 1; rows = 1; }
|
||
else if (n === 2) { cols = 2; rows = 1; }
|
||
else if (n === 3) {
|
||
if (width >= TILE_GRID_WIDE_3X1) { cols = 3; rows = 1; }
|
||
else { cols = 2; rows = 2; }
|
||
}
|
||
else if (n === 4) { cols = 2; rows = 2; }
|
||
else if (n <= 6) { cols = 3; rows = 2; }
|
||
else { cols = 3; rows = 3; }
|
||
const fits = width / cols >= TILE_MIN_W && height / rows >= TILE_MIN_H;
|
||
return { cols, rows, fits };
|
||
}
|
||
|
||
/**
|
||
* How many tiles a grid area can hold: the largest count up to TILE_GRID_MAX
|
||
* whose layout, and every smaller count's layout, fits. 0 when not even one
|
||
* tile fits.
|
||
*
|
||
* @param {{width: number, height: number}} p
|
||
* @returns {number}
|
||
*/
|
||
function tileGridCapacity({ width, height }) {
|
||
let capacity = 0;
|
||
for (let n = 1; n <= TILE_GRID_MAX; n++) {
|
||
if (!computeTileLayout({ count: n, width, height }).fits) break;
|
||
capacity = n;
|
||
}
|
||
return capacity;
|
||
}
|
||
|
||
/**
|
||
* The stored grid (`codeman:tile-grid`: session ids and the layout, never
|
||
* content) made safe to apply: unknown, deleted, detached and duplicate ids
|
||
* are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
|
||
* name a kept id, and track fractions must be 1 to 3 finite positive numbers.
|
||
* Anything that is not a v1 object (or its JSON) gives null.
|
||
*
|
||
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
|
||
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
|
||
* with no hole. `ids` comes back packed (the tiles in reading order, what
|
||
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
|
||
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
|
||
*
|
||
* `freed` names the cells whose session no longer exists (or was popped out
|
||
* to its own window) since the grid was stored: the ranking fills those first
|
||
* when the grid comes back (restoreTileGridCells). A hole the user left empty
|
||
* is not freed. `count` is how many tiles the grid had after the user's own
|
||
* last change (a session that went away by itself does not lower it), so the
|
||
* grid comes back to that many when there are sessions to fill it; a value
|
||
* stored before it existed, or a malformed one, reads as the number of
|
||
* sessions the stored cells name.
|
||
*
|
||
* @param {unknown} raw - the parsed value, or the stored JSON string
|
||
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
|
||
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
|
||
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
|
||
* focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
||
*/
|
||
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||
let value = raw;
|
||
if (typeof value === 'string') {
|
||
try { value = JSON.parse(value); } catch { return null; }
|
||
}
|
||
if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null;
|
||
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
|
||
const ids = [];
|
||
const cells = [];
|
||
const freed = [];
|
||
const named = [];
|
||
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
|
||
const isId = typeof id === 'string' && id !== '';
|
||
const present = isId && live.has(id) && !detachedIds?.has?.(id);
|
||
const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
|
||
if (keep) ids.push(id);
|
||
// Its session went away since: the cell is freed for the ranking to fill.
|
||
if (isId && !present && !named.includes(id)) freed.push(cells.length);
|
||
if (isId && !named.includes(id)) named.push(id);
|
||
// A malformed entry (not a string, not null) is a hole too.
|
||
cells.push(keep ? id : null);
|
||
}
|
||
const fractions = (fr) => {
|
||
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
|
||
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
|
||
};
|
||
const count =
|
||
Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
|
||
? value.count
|
||
: Math.min(named.length, TILE_GRID_MAX);
|
||
return {
|
||
v: 1,
|
||
open: value.open === true && ids.length > 0,
|
||
ids,
|
||
cells,
|
||
freed,
|
||
count,
|
||
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
|
||
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
|
||
colFr: fractions(value.colFr),
|
||
rowFr: fractions(value.rowFr),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A stored grid as it comes back (the Tiles button, a page reload): its cells
|
||
* exactly as stored, holes the user left included, and its focus (or the tile
|
||
* it had zoomed). Only when it holds fewer tiles than its `count` (sessions
|
||
* that went away since, or by themselves while it was open) does it fill, from
|
||
* `ranked` (best first, never a session already in it): the freed cells first,
|
||
* then the other empty cells, in reading order; more than the cells hold join
|
||
* after them (the shape grows when the grid lays them out, reformTileCells).
|
||
* A cell stays empty only when no other session is left to place. Never
|
||
* trimmed to the window: a grid larger than the window fits shows its focused
|
||
* tile alone until the window fits it again, and the arrangement stays.
|
||
*
|
||
* @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
|
||
* focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
|
||
* @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
|
||
* @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
|
||
*/
|
||
function restoreTileGridCells(stored, ranked) {
|
||
const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
|
||
const cells = [];
|
||
for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
|
||
const tiles = cells.filter(Boolean);
|
||
if (tiles.length === 0) return null;
|
||
const focusedId =
|
||
[stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
|
||
const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
|
||
const fillers = [];
|
||
for (const id of ranked || []) {
|
||
if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
|
||
}
|
||
const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
|
||
const empty = [];
|
||
cells.forEach((id, k) => {
|
||
if (id === null) empty.push(k);
|
||
});
|
||
// Freed cells first, each group in reading order.
|
||
empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
|
||
let count = tiles.length;
|
||
for (const k of empty) {
|
||
if (count >= target || fillers.length === 0) break;
|
||
cells[k] = fillers.shift();
|
||
count++;
|
||
}
|
||
const extra = fillers.slice(0, Math.max(0, target - count));
|
||
return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
|
||
}
|
||
|
||
/**
|
||
* New track fractions after a divider drag (grid-template `fr` values): the two
|
||
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
|
||
* least `minPx` (or half the pair, if the pair cannot give both the minimum).
|
||
* Every other track keeps its size. Computed from the fractions the drag
|
||
* STARTED with and the pointer's total travel, so a drag never drifts.
|
||
*
|
||
* @param {number[]} fr - the fractions when the drag started
|
||
* @param {number} index - the divider: between track `index` and `index + 1`
|
||
* @param {number} deltaPx - pointer travel since the drag started
|
||
* @param {number} totalPx - the size the tracks share (dividers and padding excluded)
|
||
* @param {number} minPx - the smallest a track may get
|
||
* @returns {number[]} new fractions, same length
|
||
*/
|
||
function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
|
||
const out = fr.slice();
|
||
if (index < 0 || index + 1 >= fr.length || !(totalPx > 0)) return out;
|
||
const sum = fr.reduce((a, b) => a + b, 0);
|
||
if (!(sum > 0)) return out;
|
||
const a = (fr[index] / sum) * totalPx;
|
||
const b = (fr[index + 1] / sum) * totalPx;
|
||
const pair = a + b;
|
||
const lo = Math.min(minPx, pair / 2);
|
||
const hi = pair - lo;
|
||
const nextA = Math.min(Math.max(a + (Number(deltaPx) || 0), lo), hi);
|
||
out[index] = (nextA / totalPx) * sum;
|
||
out[index + 1] = ((pair - nextA) / totalPx) * sum;
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
|
||
* ones a count fills a grid with), in the order given (tab order, or the
|
||
* ranking): live ones only, never a session popped out to
|
||
* its own window (that window owns its PTY size). A session with no PTY
|
||
* attached IS offered: its tile shows the Attach overlay.
|
||
*
|
||
* @param {Map<string, {name?: string}>} sessions
|
||
* @param {string[]} sessionOrder
|
||
* @param {{has(id: string): boolean}} [detachedIds]
|
||
* @returns {Array<{id: string, label: string}>}
|
||
*/
|
||
function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
|
||
const result = [];
|
||
for (const id of sessionOrder) {
|
||
if (detachedIds?.has?.(id)) continue;
|
||
const session = sessions.get(id);
|
||
if (!session || result.some((r) => r.id === id)) continue;
|
||
result.push({ id, label: session.name || 'Session' });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// Ranking groups (rankTileSessions): working first, then the sessions waiting
|
||
// on the user, then everything else.
|
||
const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
|
||
|
||
/**
|
||
* The stamp a session is ranked by inside its group. A WORKING session keys
|
||
* off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
|
||
* about once a second, so its last-activity stamp is always "now", and one
|
||
* that never submitted would otherwise claim the head of the group. 0 means
|
||
* unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
|
||
* the last byte the pane printed, i.e. when it went quiet).
|
||
*/
|
||
function tileRankStamp(row) {
|
||
if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
|
||
return sessionActivityAnchor(row);
|
||
}
|
||
|
||
/**
|
||
* Which open sessions the tile grid shows when nobody said which (the Tiles
|
||
* button with no stored grid to bring back, and every place the grid fills a
|
||
* tile on its own: a count picked in its menu, a freed cell), best first
|
||
* (owner request: "prefer to load in tiles that are working and then the most
|
||
* recent, so the oldest don't get opened"):
|
||
* 1. WORKING, the most recently started turn first;
|
||
* 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
|
||
* permission or question dialog; `waiting`: a finished turn not seen
|
||
* yet), most recent first;
|
||
* 3. then every other one (idle, done, error), most recently active first.
|
||
* Inside a group the newest stamp wins (tileRankStamp) and a session with no
|
||
* stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
|
||
* the result never shuffles. The states are the home screens' own
|
||
* (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
|
||
* order differs: the home screens put the longest-running turn first, the grid
|
||
* the most recent.
|
||
*
|
||
* Pure. Unit-tested in test/tile-grid-ranking.test.ts.
|
||
*
|
||
* @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
|
||
* @returns {string[]} the ids, best first, each once
|
||
*/
|
||
function rankTileSessions(rows) {
|
||
const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
|
||
const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
|
||
list.sort((a, b) => {
|
||
const byGroup = group(a) - group(b);
|
||
if (byGroup !== 0) return byGroup;
|
||
const atA = tileRankStamp(a);
|
||
const atB = tileRankStamp(b);
|
||
if (atA !== atB) {
|
||
if (!atA) return 1;
|
||
if (!atB) return -1;
|
||
return atB - atA;
|
||
}
|
||
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
||
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
||
return orderA - orderB;
|
||
});
|
||
const ids = [];
|
||
for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
|
||
return ids;
|
||
}
|
||
|
||
/**
|
||
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
||
* (owner decision 8). In order:
|
||
* a. the grid this tab last had (`stored`, already sanitized: live, not
|
||
* detached, at most the cap), if any of its sessions survive, exactly
|
||
* as it was (restoreTileGridCells: its cells and holes, a cell its
|
||
* session freed filled from the ranking);
|
||
* b. else an open split's two sessions, Pane A focused;
|
||
* c. else the open sessions in `ranked` order (rankTileSessions: working,
|
||
* then needing input, then the most recent; tab order when no ranking is
|
||
* given), detached ones never, up to `limit`, the active session always
|
||
* among them and focused (when it ranks past the limit, the first
|
||
* `limit - 1` others come with it).
|
||
* Null when there is nothing to open.
|
||
*
|
||
* @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
|
||
* focused: string|null, zoomed: string|null}|null,
|
||
* split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
||
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
|
||
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
|
||
* focusedId: string|null}|null}
|
||
*/
|
||
function tileGridOpenSet({
|
||
stored = null,
|
||
split = null,
|
||
ranked = null,
|
||
sessions,
|
||
sessionOrder,
|
||
detachedIds,
|
||
activeId = null,
|
||
limit,
|
||
}) {
|
||
const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
|
||
(c) => c.id
|
||
);
|
||
const restored = stored ? restoreTileGridCells(stored, all) : null;
|
||
if (restored) return { source: 'stored', ...restored };
|
||
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
|
||
const pair = (split || []).filter(usable);
|
||
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
||
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
|
||
if (all.length === 0) return null;
|
||
let ids = all.slice(0, max);
|
||
if (all.includes(activeId) && !ids.includes(activeId)) {
|
||
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
|
||
}
|
||
return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
||
}
|
||
|
||
/**
|
||
* The tile counts the Tiles button's right-click menu offers, and the count a
|
||
* click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md).
|
||
*/
|
||
const TILE_GRID_COUNTS = [2, 4, 6];
|
||
const TILE_GRID_COUNT_DEFAULT = 6;
|
||
|
||
/** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */
|
||
function sanitizeTileCount(raw) {
|
||
const n = Number(raw);
|
||
return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT;
|
||
}
|
||
|
||
/**
|
||
* `base` (what the grid would open, or what an open grid shows, in its order)
|
||
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
|
||
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
|
||
* tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
|
||
* the app passes the ranking, rankTileSessions) with the ones not in it yet.
|
||
* Fewer sessions than `n` give fewer tiles.
|
||
*
|
||
* @param {string[]} base
|
||
* @param {string[]} all
|
||
* @param {number} n - at most TILE_GRID_MAX
|
||
* @param {string|null} [keepId]
|
||
* @returns {string[]}
|
||
*/
|
||
function tileGridSetForCount(base, all, n, keepId = null) {
|
||
const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX));
|
||
const ids = [];
|
||
for (const id of [...(base || []), ...(all || [])]) {
|
||
if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id);
|
||
}
|
||
// Every `base` id comes before every filler, so a trim never drops a base id
|
||
// in favour of one.
|
||
let out = ids.slice(0, max);
|
||
if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId];
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Which tile takes focus when `id` leaves the grid: the next one in grid
|
||
* order, else the previous one, else null.
|
||
*
|
||
* @param {string[]} ids - the grid's tiles, in reading order
|
||
* @param {string} id - the tile that is leaving
|
||
* @returns {string|null}
|
||
*/
|
||
function tileNeighbor(ids, id) {
|
||
const i = ids.indexOf(id);
|
||
if (i === -1) return ids[0] ?? null;
|
||
return ids[i + 1] ?? ids[i - 1] ?? null;
|
||
}
|
||
|
||
/**
|
||
* The tile a directional focus chord moves to, in a row-major grid of `cols`
|
||
* columns whose empty cells are `null` (a hole can be any cell) or simply
|
||
* missing at the end. Focus never lands on a hole. Left and right go along
|
||
* the row, past any hole, and never leave it. Up and down take the nearest
|
||
* row in that direction that has a tile: the tile in the same column, else
|
||
* the one in the nearest column (the lower column on a tie), so moving down
|
||
* onto a short or holed last row lands on its nearest tile. Null when there
|
||
* is no tile in that direction.
|
||
*
|
||
* @param {(string|null)[]} cells - the grid's cells in reading order (or its packed tiles)
|
||
* @param {string} focusedId - the tile the keyboard is in
|
||
* @param {'left'|'right'|'up'|'down'} direction
|
||
* @param {number} cols - the layout's column count
|
||
* @returns {string|null}
|
||
*/
|
||
function tileInDirection(cells, focusedId, direction, cols) {
|
||
const i = focusedId ? cells.indexOf(focusedId) : -1;
|
||
if (i === -1 || !(cols >= 1)) return null;
|
||
const rows = Math.ceil(cells.length / cols);
|
||
const row = Math.floor(i / cols);
|
||
const col = i % cols;
|
||
const at = (r, c) => cells[r * cols + c] || null;
|
||
if (direction === 'left' || direction === 'right') {
|
||
const step = direction === 'left' ? -1 : 1;
|
||
for (let c = col + step; c >= 0 && c < cols; c += step) {
|
||
if (at(row, c)) return at(row, c);
|
||
}
|
||
return null;
|
||
}
|
||
if (direction !== 'up' && direction !== 'down') return null;
|
||
const step = direction === 'up' ? -1 : 1;
|
||
for (let r = row + step; r >= 0 && r < rows; r += step) {
|
||
let best = null;
|
||
let bestDistance = Infinity;
|
||
for (let c = 0; c < cols; c++) {
|
||
const id = at(r, c);
|
||
if (id && Math.abs(c - col) < bestDistance) {
|
||
best = id;
|
||
bestDistance = Math.abs(c - col);
|
||
}
|
||
}
|
||
if (best) return best;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The cell next to cell `index` in that direction (Move Tile: a tile moves
|
||
* into an empty neighbour cell, or swaps with a tiled one), or -1 at the
|
||
* edge. Adjacent only: a move never jumps over a cell.
|
||
*
|
||
* @param {number} index - the cell, in reading order
|
||
* @param {'left'|'right'|'up'|'down'} direction
|
||
* @param {number} cols - the layout's column count
|
||
* @param {number} cellCount - cols x rows
|
||
* @returns {number}
|
||
*/
|
||
function tileCellInDirection(index, direction, cols, cellCount) {
|
||
if (!(cols >= 1) || index < 0 || index >= cellCount) return -1;
|
||
const col = index % cols;
|
||
let j = -1;
|
||
if (direction === 'left') j = col > 0 ? index - 1 : -1;
|
||
else if (direction === 'right') j = col < cols - 1 ? index + 1 : -1;
|
||
else if (direction === 'up') j = index - cols;
|
||
else if (direction === 'down') j = index + cols;
|
||
return j >= 0 && j < cellCount ? j : -1;
|
||
}
|
||
|
||
/**
|
||
* The grid's cells after its shape changed (or to fill one for the first
|
||
* time): `cols` x `rows` cells, `null` for an empty one. The same shape keeps
|
||
* every cell as it is. A new shape keeps each tile at its row and column when
|
||
* every tile still fits there (2x2 growing to 3x2: the four tiles stay put),
|
||
* and otherwise packs the tiles in reading order from the first cell, holes
|
||
* collapsed (positions do not map between shapes). `oldCols` 0 (no layout
|
||
* yet) always packs.
|
||
*
|
||
* @param {(string|null)[]} cells - the current cells, laid out `oldCols` wide
|
||
* @param {number} oldCols - the column count they were laid out with
|
||
* @param {number} cols
|
||
* @param {number} rows
|
||
* @returns {(string|null)[]}
|
||
*/
|
||
function fitTileCells(cells, oldCols, cols, rows) {
|
||
const size = Math.max(0, cols * rows);
|
||
if (oldCols === cols && cells.length === size) return cells.slice();
|
||
const out = new Array(size).fill(null);
|
||
const placed = cells.map((id, k) => (id ? { id, row: Math.floor(k / oldCols), col: k % oldCols } : null));
|
||
const keep = oldCols >= 1 && placed.every((p) => !p || (p.row < rows && p.col < cols));
|
||
if (keep) {
|
||
for (const p of placed) if (p) out[p.row * cols + p.col] = p.id;
|
||
return out;
|
||
}
|
||
cells.filter(Boolean).slice(0, size).forEach((id, k) => {
|
||
out[k] = id;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* How many columns a grid of `length` cells was laid out with: stored cells
|
||
* carry no shape of their own, and the layout table gives each cell count one
|
||
* shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other
|
||
* length (fitTileCells then packs).
|
||
*
|
||
* @param {number} length
|
||
* @returns {number}
|
||
*/
|
||
function tileCellCols(length) {
|
||
// Every cell count is some count's shape on a wide grid area (2x2, the
|
||
// narrow 3-tile shape, is also the 4-tile one).
|
||
for (let n = 1; n <= TILE_LAYOUT_MAX; n++) {
|
||
const { cols, rows } = computeTileLayout({ count: n });
|
||
if (cols * rows === length) return cols;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* The cells of a grid re-formed to another set of tiles (a count picked in the
|
||
* Tiles menu, or the Tiles button bringing back a remembered grid with more or
|
||
* fewer tiles): the tiles in `keep` stay in their cells and every other cell
|
||
* empties, then the cell model's shape rule (fitTileCells: each tile keeps its
|
||
* row and column when all fit, else they pack in reading order), then the
|
||
* tiles in `add` fill the empty cells in reading order, holes first.
|
||
*
|
||
* @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide
|
||
* @param {number} oldCols
|
||
* @param {string[]} keep - tiles that stay
|
||
* @param {string[]} add - tiles that join, in the order they fill
|
||
* @param {number} cols - the new shape
|
||
* @param {number} rows
|
||
* @returns {(string|null)[]}
|
||
*/
|
||
function reformTileCells(cells, oldCols, keep, add, cols, rows) {
|
||
const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null));
|
||
const out = fitTileCells(kept, oldCols, cols, rows);
|
||
for (const id of add) {
|
||
if (!id || out.includes(id)) continue;
|
||
const k = out.indexOf(null);
|
||
// Not for a shape made for the count; a full grid takes no more.
|
||
if (k === -1) break;
|
||
out[k] = id;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the
|
||
* grid is open: tiles cycle in reading order and wrap.
|
||
*
|
||
* @param {string[]} ids
|
||
* @param {string} focusedId
|
||
* @param {number} delta - +1 or -1
|
||
* @returns {string|null}
|
||
*/
|
||
function cycleTile(ids, focusedId, delta) {
|
||
if (ids.length === 0) return null;
|
||
const i = ids.indexOf(focusedId);
|
||
if (i === -1) return ids[0];
|
||
return ids[(i + delta + ids.length) % ids.length];
|
||
}
|
||
|
||
// ── Renderer liveness ──────────────────────────────────────────────────────
|
||
//
|
||
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
||
// the background — not deferred, never delivered. xterm's RenderDebouncer only
|
||
// clears its `_animationFrame` handle from INSIDE that callback:
|
||
//
|
||
// refresh() {
|
||
// if (this._animationFrame !== undefined) return; // <- stale forever
|
||
// this._animationFrame = requestAnimationFrame(() => this._innerRefresh());
|
||
// }
|
||
// _innerRefresh() { this._animationFrame = undefined; ... } // never runs
|
||
//
|
||
// So after one backgrounding the handle is permanently non-undefined and EVERY
|
||
// later render request returns on line one. Parsing is decoupled from
|
||
// rendering, so bytes keep filling the buffer correctly and nothing throws —
|
||
// the terminal is simply frozen. Closing and reopening fixes it because that
|
||
// constructs a new Terminal, and therefore a new debouncer.
|
||
//
|
||
// Codeman is MORE exposed than a per-session-terminal app: there is exactly one
|
||
// xterm instance for the whole page load, so a single backgrounding can wedge
|
||
// it until a full reload.
|
||
//
|
||
// This is the pure decision half. The signature that distinguishes this from
|
||
// every other way a terminal can look stuck is that bytes were WRITTEN and the
|
||
// element is VISIBLE, yet onRender has not fired since:
|
||
//
|
||
// frozen = wroteAt > renderedAt && now - wroteAt >= threshold && visible
|
||
//
|
||
// Deliberately NOT a "no output at all" check: a quiet terminal is the normal
|
||
// state and must never be kicked. And `visible` is required because a hidden
|
||
// terminal legitimately stops rendering (xterm pauses it), so kicking there
|
||
// would fire constantly on every backgrounded tab.
|
||
const RENDER_STALL_MS = 4000;
|
||
|
||
// How often the watchdog checks. Deliberately coarse: the failure it catches is
|
||
// permanent until healed, so detecting it a second late costs nothing, while a
|
||
// tight interval would burn a wakeup per second on every idle phone.
|
||
const RENDER_LIVENESS_POLL_MS = 2000;
|
||
|
||
/**
|
||
* Should the renderer be kicked? Pure so the CI gate can cover it — the DOM
|
||
* half (cancelling the stale handle) lives in terminal-ui.js.
|
||
*
|
||
* @param {{wroteAt:number, renderedAt:number, now:number, visible:boolean,
|
||
* thresholdMs?:number}} s
|
||
* @returns {boolean}
|
||
*/
|
||
function shouldKickRenderer(s) {
|
||
if (!s || !s.visible) return false;
|
||
const wroteAt = Number(s.wroteAt) || 0;
|
||
const renderedAt = Number(s.renderedAt) || 0;
|
||
const now = Number(s.now) || 0;
|
||
// Nothing written yet — a fresh terminal has no render to be missing.
|
||
if (wroteAt <= 0) return false;
|
||
// A render landed at or after the last write: the pipeline is alive.
|
||
if (renderedAt >= wroteAt) return false;
|
||
const threshold = Number.isFinite(s.thresholdMs) && s.thresholdMs > 0 ? s.thresholdMs : RENDER_STALL_MS;
|
||
return now - wroteAt >= threshold;
|
||
}
|
||
|
||
// ── Fetch deadlines ────────────────────────────────────────────────────────
|
||
//
|
||
// No terminal fetch carried any deadline, including `?full=1`, which the code
|
||
// itself describes as "unbounded-ish work: at the default history limit it can
|
||
// be megabytes". On a stalled mobile link that request hangs on the browser
|
||
// default with no retry and no path back to a usable terminal short of a
|
||
// reload.
|
||
//
|
||
// A single fixed timeout is wrong in both directions — too short for a full
|
||
// scrollback capture on a slow uplink, too long for a small tail on a dead
|
||
// connection. So the deadline is scaled by what is actually being asked for,
|
||
// and by how many captures are already in flight: on a slow link those bytes
|
||
// must drain before this request's own bytes start moving, and its timer is
|
||
// already running the whole time.
|
||
const FETCH_DEADLINE_TAIL_MS = 15000;
|
||
const FETCH_DEADLINE_FULL_MS = 45000;
|
||
const FETCH_DEADLINE_MAX_MS = 120000;
|
||
|
||
/**
|
||
* Deadline in ms for a terminal capture.
|
||
*
|
||
* @param {{full?:boolean, inflight?:number}} s - `full` = the ?full=1 capture;
|
||
* `inflight` = captures already running (this one included or not, it only
|
||
* scales the budget).
|
||
* @returns {number}
|
||
*/
|
||
function terminalFetchDeadlineMs(s) {
|
||
const full = !!(s && s.full);
|
||
const base = full ? FETCH_DEADLINE_FULL_MS : FETCH_DEADLINE_TAIL_MS;
|
||
const inflight = Math.max(0, Number(s && s.inflight) || 0);
|
||
// Each already-queued capture gets the newcomer one more base budget to wait
|
||
// through. Linear rather than clever: the point is only that eight tabs
|
||
// resuming do not all time out together because each assumed it was alone.
|
||
return Math.min(FETCH_DEADLINE_MAX_MS, base * (1 + inflight));
|
||
}
|
||
|
||
// ── Diagnostics hygiene ────────────────────────────────────────────────────
|
||
//
|
||
// The crash trail is joined with '\n' into ONE localStorage value and beaconed
|
||
// to the server, and at least one call site interpolates server-controlled text
|
||
// (a WebSocket close `reason`). An embedded newline there forges extra entries
|
||
// in the trail; an unbounded string can fill the storage quota. Both are cheap
|
||
// to close, and the trail is something a user may be asked to paste into an
|
||
// issue.
|
||
const DIAG_ENTRY_MAX_CHARS = 300;
|
||
|
||
/** Flatten a diagnostic message to one bounded, newline-free line. */
|
||
function sanitizeDiagEntry(msg) {
|
||
return String(msg == null ? '' : msg)
|
||
.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
||
.slice(0, DIAG_ENTRY_MAX_CHARS);
|
||
}
|
||
|
||
// ── Recovering a dropped output frame ──────────────────────────────────────
|
||
//
|
||
// `_onSessionTerminal` drops an incoming frame when the app-owned render queues
|
||
// already hold 128KB, which is the right call — the alternative is an unbounded
|
||
// backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced
|
||
// cursor is muffled text (issue #464). So the drop is only half of it: the
|
||
// recovery has to actually happen.
|
||
//
|
||
// ⚠️ It used to be a fire-and-forget timer. `_onSessionNeedsRefresh` opens with
|
||
// four early returns, and two of them — a buffer load in flight, a refresh
|
||
// already owning this session — are MOST likely to be true during exactly the
|
||
// output burst that caused the drop. The timer nulled itself before the call,
|
||
// so a skipped refresh lost the recovery silently and the dropped bytes were
|
||
// never replayed.
|
||
//
|
||
// Bounded, because the early returns it retries past are transient contention
|
||
// that clears in seconds, and a permanently failing refresh must not become a
|
||
// forever-loop against the API. A refresh that hit the capture fetch DEADLINE
|
||
// is not contention but a stalled link, and is not retried at all: each retry
|
||
// would be another `?full=1` capture waiting out a deadline of up to two
|
||
// minutes, where the old code cost exactly one. Giving up after the cap leaves
|
||
// exactly the garbled frames the old code left, so the floor is no worse.
|
||
const DROP_RECOVERY_DELAY_MS = 2000;
|
||
const DROP_RECOVERY_MAX_ATTEMPTS = 5;
|
||
|
||
/**
|
||
* Should a dropped-output recovery run again?
|
||
*
|
||
* @param {{repainted: boolean, timedOut?: boolean, attempt: number, stillActive: boolean}} state
|
||
* `repainted` — whether `_onSessionNeedsRefresh` actually rewrote the buffer.
|
||
* `timedOut` - whether it failed at the capture fetch deadline.
|
||
* `attempt` — how many have already run, zero-based.
|
||
* `stillActive` — whether the dropped session is still the one on screen.
|
||
* @returns {boolean}
|
||
*/
|
||
function shouldRetryDroppedOutputRecovery({ repainted, timedOut = false, attempt, stillActive }) {
|
||
// Switched away: `selectSession` repaints from the server on its own, so a
|
||
// retry here would be a second replay of a buffer that is about to be written.
|
||
if (!stillActive) return false;
|
||
if (repainted) return false;
|
||
if (timedOut) return false;
|
||
return attempt + 1 < DROP_RECOVERY_MAX_ATTEMPTS;
|
||
}
|
||
|
||
// ── Terminal geometry: xterm and the PTY must never disagree ───────────────
|
||
//
|
||
// Issue #464 ("text gets muffled"). Claude Code's TUI repaints by wrapping its
|
||
// frame at the width the PTY reported and walking the cursor up that many
|
||
// ROWS. So a browser terminal whose width differs from the PTY's makes every
|
||
// repaint arithmetic wrong: a logical line occupies more physical rows than
|
||
// Ink counted, `eraseLines(n)` clears too few of them, and the new frame paints
|
||
// over rows that were never erased. Measured against a real xterm — a PTY
|
||
// believing 120 columns against a 62-column terminal renders each wrapped line
|
||
// twice, and a shorter replacement line leaves the tail of the old one behind.
|
||
// That is exactly the doubled rows and half-overwritten prose in the report.
|
||
//
|
||
// The floor exists because a PTY a handful of columns wide makes any CLI wrap
|
||
// every word; it is NOT a display preference, so the browser terminal has to
|
||
// honour it too. Three separate call sites used to fit xterm to the RAW
|
||
// proposal and report the CLAMPED one, which is how the two drifted apart with
|
||
// nothing to notice: resize is write-only, so nobody could see the disagreement.
|
||
const TERMINAL_MIN_COLS = 40;
|
||
const TERMINAL_MIN_ROWS = 10;
|
||
|
||
/**
|
||
* The geometry to apply AND report — there is only ever one answer to both.
|
||
* @param {{cols: number, rows: number}|null|undefined} proposed
|
||
* @returns {{cols: number, rows: number}|null}
|
||
*/
|
||
function clampTerminalDimensions(proposed) {
|
||
if (!proposed || !Number.isFinite(proposed.cols) || !Number.isFinite(proposed.rows)) return null;
|
||
return {
|
||
cols: Math.max(Math.trunc(proposed.cols), TERMINAL_MIN_COLS),
|
||
rows: Math.max(Math.trunc(proposed.rows), TERMINAL_MIN_ROWS),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* What to do when the server reports the PTY's real geometry.
|
||
*
|
||
* The server is the authority: it owns the PTY the CLI is drawing for, and it
|
||
* can refuse a resize outright (`Session.resize` ignores small-viewport
|
||
* requests while a desktop connection holds an active sizing claim) without
|
||
* the asking client ever being told. A terminal that keeps its own WIDTH after
|
||
* such a refusal renders garbage, because Ink wraps its frame and counts its
|
||
* erase rows at the width it was told.
|
||
*
|
||
* ⚠️ COLUMNS ONLY. Rows are deliberately left alone, and adopting them was a
|
||
* real regression: a phone that took a desktop's 43 rows into a viewport with
|
||
* room for 18 painted an `.xterm-screen` far taller than its container, and
|
||
* because xterm's own viewport then had nothing to scroll, the bottom of the
|
||
* frame — the CLI's input line — sat below the container with no gesture that
|
||
* could reach it. Output visible, typing invisible, for as long as the claim
|
||
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
|
||
* decide how much is on screen at once, and keeping the local row count keeps
|
||
* the composer at the bottom of a viewport that scrolls. The one deliberate
|
||
* screen taller than its box is this device's own rows held under the
|
||
* on-screen keyboard, which keyboardClipPx keeps the composer in view for.
|
||
*
|
||
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
|
||
* @param {{cols: number, rows: number}|null} pty - what the server just reported
|
||
* @returns {{adopt: boolean, cols: number|null}}
|
||
*/
|
||
function reconcilePtyGeometry(local, pty) {
|
||
if (!pty || !Number.isFinite(pty.cols)) return { adopt: false, cols: null };
|
||
if (!local || !Number.isFinite(local.cols) || local.cols === pty.cols) return { adopt: false, cols: null };
|
||
return { adopt: true, cols: pty.cols };
|
||
}
|
||
|
||
/** Rows below the last content within which the cursor still counts (keyboardClipPx). */
|
||
const KEYBOARD_CLIP_CURSOR_REACH = 2;
|
||
|
||
/**
|
||
* How far to scroll the terminal SCREEN up while its rows are held under the
|
||
* on-screen keyboard (setting `keyboardHoldsRows`).
|
||
*
|
||
* Every PTY height change makes Claude Code clear the screen and reprint only
|
||
* the transcript tail that fits, so rows above that tail are lost from tmux and
|
||
* older ones stay in history as stale copies. The keyboard opening and closing
|
||
* was such a change each time. Instead the rows stay as they were and
|
||
* `.xterm-screen` (taller than its box now) is translated up by this many px.
|
||
*
|
||
* This is the one deliberate exception to "a screen taller than its container
|
||
* hides the input line" (see reconcilePtyGeometry): the offset below is what
|
||
* keeps the live content in view.
|
||
*
|
||
* - Following live output: the last row with content sits at the bottom of the
|
||
* box. A fresh shell prompting on row 1 shows the top; Claude's footer under
|
||
* its composer stays visible. The cursor counts only when it is right below
|
||
* that content (a prompt or composer line still empty): a TUI that parks its
|
||
* cursor far below what it drew (codex's dialogs, with the cursor on the last
|
||
* row of an otherwise blank screen) must not scroll its content out of view.
|
||
* - Reading history: the offset follows the scroll position, so the top of the
|
||
* scrollback is reachable and scrolling stays continuous.
|
||
*
|
||
* Recomputed on every render with no hysteresis; add some if a TUI's mid-redraw
|
||
* frames ever make the view jitter.
|
||
*
|
||
* @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number,
|
||
* lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input
|
||
* lastContentRow: the last non-blank screen row, or -1 when the screen is blank
|
||
* @returns {number} px, between 0 and the screen's overflow
|
||
*/
|
||
function keyboardClipPx(input) {
|
||
const { rows, cellHeight, viewHeight, cursorRow, lastContentRow, following, viewportY, baseY } = input;
|
||
if (!(cellHeight > 0) || !(viewHeight > 0) || !(rows > 0)) return 0;
|
||
const max = Math.max(0, rows * cellHeight - viewHeight);
|
||
if (max === 0) return 0;
|
||
if (!following && baseY > 0) return Math.round(max * Math.min(1, Math.max(0, viewportY / baseY)));
|
||
const cursorCounts = lastContentRow < 0 || cursorRow <= lastContentRow + KEYBOARD_CLIP_CURSOR_REACH;
|
||
const contentBottomRow = cursorCounts ? Math.max(cursorRow, lastContentRow) : lastContentRow;
|
||
const bottom = (Math.min(rows - 1, Math.max(0, contentBottomRow)) + 1) * cellHeight;
|
||
return Math.round(Math.min(max, Math.max(0, bottom - viewHeight)));
|
||
}
|
||
|
||
/**
|
||
* How many screen rows, from the top, are fully visible at a given offset: the
|
||
* local-echo overlay grows upward from the last of them (`setRowLimit`).
|
||
* @returns {number}
|
||
*/
|
||
function keyboardVisibleRowLimit(clipPx, viewHeight, cellHeight) {
|
||
if (!(cellHeight > 0)) return 0;
|
||
return Math.max(1, Math.floor((clipPx + viewHeight) / cellHeight));
|
||
}
|
||
|
||
/**
|
||
* Which session does a dashboard URL's fragment ask for? Another page that
|
||
* holds the dashboard's window, such as a task board, points it at
|
||
* `/#session=<id>`. Only the fragment changes between two such links, so the
|
||
* browser keeps the page loaded and fires `hashchange`, and the dashboard
|
||
* switches tabs without reloading. Any other fragment asks for nothing.
|
||
*
|
||
* @param {string} hash - `location.hash`, with or without its leading `#`
|
||
* @returns {string|null} the session id, or null
|
||
*/
|
||
function sessionIdFromFragment(hash) {
|
||
const params = new URLSearchParams(String(hash || '').replace(/^#/, ''));
|
||
const id = params.get('session');
|
||
return id && id.trim() ? id.trim() : null;
|
||
}
|
||
|
||
/** Longest model name a session header shows (the server caps it as well). */
|
||
const SESSION_MODEL_MAX_CHARS = 64;
|
||
/** A CLI registry id (src/config/cli-registry/schema.ts); anything else is not a class name. */
|
||
const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
|
||
|
||
/**
|
||
* What a session's header says about its harness: the CLI id (the
|
||
* `run-mode-dot <id>` logo class), the registry's label for it, the model the
|
||
* session runs when the server knows it (`SessionState.displayModel`), and the
|
||
* tooltip naming both.
|
||
*
|
||
* The id is data: the label comes from the injected CLI catalog and falls back
|
||
* to the id, so a CLI added through clis.json still gets a name. The model is
|
||
* untrusted text (read off a pane, or a CLI's own report): control characters
|
||
* are dropped and the length capped here too, and callers render it with
|
||
* textContent. The tooltip says where a model that is not the CLI's own report
|
||
* came from, so it never claims more than the server knows: one the session
|
||
* was launched with may have been switched since, one read from the CLI's
|
||
* config is what it is configured to run, and a custom endpoint's model is the
|
||
* endpoint's, whatever the CLI calls it.
|
||
*
|
||
* @param {object} session - a session from app.sessions
|
||
* @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog
|
||
* @returns {{id: string, label: string, model: string, title: string}}
|
||
*/
|
||
function describeSessionHarness(session, catalog) {
|
||
const id = typeof session?.mode === 'string' && CLI_ID_PATTERN.test(session.mode) ? session.mode : '';
|
||
const entry = id && Array.isArray(catalog) ? catalog.find((cli) => cli?.id === id) : null;
|
||
const label = (typeof entry?.label === 'string' && entry.label.trim()) || id;
|
||
const raw = session?.displayModel?.model;
|
||
const model =
|
||
typeof raw === 'string'
|
||
? raw
|
||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
|
||
.trim()
|
||
.slice(0, SESSION_MODEL_MAX_CHARS)
|
||
: '';
|
||
const source = session?.displayModel?.source;
|
||
const qualifier = !model
|
||
? ''
|
||
: source === 'launch'
|
||
? ' (set at launch)'
|
||
: source === 'custom-endpoint'
|
||
? ' (custom endpoint)'
|
||
: source === 'config'
|
||
? ' (from config)'
|
||
: '';
|
||
const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier;
|
||
return { id, label, model, title };
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
|
||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||
window.CodemanFilePaths = {
|
||
absoluteFilePathPattern,
|
||
findFilePathLinks,
|
||
resolveLinkedFilePath,
|
||
relativeResolutionBase,
|
||
previewsInFileViewer,
|
||
FILE_PREVIEW_EXTENSIONS,
|
||
rendersAsPage,
|
||
HTML_PAGE_EXTENSIONS,
|
||
};
|
||
window.CodemanTerminalLines = {
|
||
terminalLogicalLine,
|
||
terminalPathLine,
|
||
terminalLinkLine,
|
||
terminalUrlContinuationExtent,
|
||
findTerminalUrls,
|
||
terminalCellWrappedPaths,
|
||
};
|
||
window.CodemanUrlSession = { sessionIdFromFragment };
|
||
window.CodemanSplitPane = {
|
||
clampDividerPercent,
|
||
buildSplitPickerSessions,
|
||
SPLIT_PANE_MIN_WIDTH,
|
||
};
|
||
window.CodemanTileGrid = {
|
||
computeTileLayout,
|
||
tileGridCapacity,
|
||
sanitizeTileGridState,
|
||
buildTilePickerSessions,
|
||
dragTrackFractions,
|
||
tileNeighbor,
|
||
tileInDirection,
|
||
tileCellInDirection,
|
||
fitTileCells,
|
||
cycleTile,
|
||
tileGridOpenSet,
|
||
restoreTileGridCells,
|
||
rankTileSessions,
|
||
sanitizeTileCount,
|
||
tileGridSetForCount,
|
||
tileCellCols,
|
||
reformTileCells,
|
||
TILE_GRID_COUNTS,
|
||
TILE_GRID_COUNT_DEFAULT,
|
||
TILE_GRID_MAX,
|
||
TILE_LAYOUT_MAX,
|
||
TILE_MIN_W,
|
||
TILE_MIN_H,
|
||
TILE_SCROLLBACK,
|
||
TILE_FONT_SIZE_DEFAULT,
|
||
TILE_GRID_SSE_FILTER,
|
||
};
|
||
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
|
||
window.CodemanFetchDeadline = {
|
||
terminalFetchDeadlineMs,
|
||
FETCH_DEADLINE_TAIL_MS,
|
||
FETCH_DEADLINE_FULL_MS,
|
||
FETCH_DEADLINE_MAX_MS,
|
||
};
|
||
window.CodemanDiag = { sanitizeDiagEntry, DIAG_ENTRY_MAX_CHARS };
|
||
window.CodemanDroppedOutput = {
|
||
shouldRetryDroppedOutputRecovery,
|
||
DROP_RECOVERY_DELAY_MS,
|
||
DROP_RECOVERY_MAX_ATTEMPTS,
|
||
};
|
||
window.CodemanTerminalGeometry = {
|
||
clampTerminalDimensions,
|
||
reconcilePtyGeometry,
|
||
keyboardClipPx,
|
||
keyboardVisibleRowLimit,
|
||
TERMINAL_MIN_COLS,
|
||
TERMINAL_MIN_ROWS,
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Security notices
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// A security notice is a sticky warning toast about something outside Codeman that
|
||
// its users should act on (the first: OpenCode's Console breach, October 2026). The
|
||
// notices are DATA in the hand-maintained src/web/public/security-notices.json, never
|
||
// in whats-new.json (`npm run version-packages` rewrites that one every release), and
|
||
// they ship with a release. This is the pure half: which notices this browser shows, and
|
||
// on which surface. The wiring (fetches, the seen lists) is checkWhatsNew() in settings-ui.js,
|
||
// the cards are showSecurityNotice() / showUpdateNotice() in panels-ui.js.
|
||
|
||
/** `YYYY-MM-DD`, and a real calendar day (no 2026-02-30). */
|
||
const SECURITY_NOTICE_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||
/** An https URL with a plain host: no other scheme, no userinfo, no whitespace. */
|
||
const SECURITY_NOTICE_URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?(?:[/?#]\S*)?$/;
|
||
|
||
/**
|
||
* The local-time bounds of one `YYYY-MM-DD` day, or null when the string is not one.
|
||
* Local time on purpose: a notice dated the 18th starts on the viewer's 18th.
|
||
*/
|
||
function securityNoticeDay(value) {
|
||
const match = typeof value === 'string' ? SECURITY_NOTICE_DATE_PATTERN.exec(value) : null;
|
||
if (!match) return null;
|
||
const year = Number(match[1]);
|
||
const month = Number(match[2]) - 1;
|
||
const day = Number(match[3]);
|
||
const start = new Date(year, month, day);
|
||
if (start.getFullYear() !== year || start.getMonth() !== month || start.getDate() !== day) return null;
|
||
const end = new Date(year, month, day + 1).getTime() - 1;
|
||
return { start: start.getTime(), end };
|
||
}
|
||
|
||
function securityNoticeText(value) {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||
}
|
||
|
||
/**
|
||
* The notices to show, in file order, each as `{ id, title, message, label, url }`
|
||
* (`title` is '' when the entry has none).
|
||
*
|
||
* - A malformed entry is skipped, never repaired: no id, message or link label, a
|
||
* link that is not https, a date that is not a real `YYYY-MM-DD` day, a `title`
|
||
* that is present but not a non-blank string, or a `cli` that is not a registry
|
||
* id. A repeated id shows its first entry only.
|
||
* - Shown from the start of the `published` day to the END of the `expires` day.
|
||
* The expiry is what keeps an old notice from alarming a browser that first opens
|
||
* Codeman months later.
|
||
* - An id in `seen` is skipped (the user closed it, or followed its link).
|
||
* - A notice naming a `cli` is shown ONLY when `available[cli] === true`. Absent,
|
||
* unknown and false all mean "not installed" here, unlike isCliAvailable() in
|
||
* settings-ui.js (which offers a launch on a doubt): a warning about software you
|
||
* do not have is noise. A notice with no `cli` is for everyone.
|
||
* - `lang` 'zh-CN' takes the entry's `i18n['zh-CN']` title, message and label where
|
||
* they exist, each falling back to the English one on its own.
|
||
*
|
||
* The CLI id is data from the JSON, compared as a key; no code here names one.
|
||
*
|
||
* @param {Array<object>} notices - `notices` from security-notices.json
|
||
* @param {{available?: Record<string, boolean>, seen?: Iterable<string>, now?: number, lang?: string}} [opts]
|
||
* `available` is window.__codemanCliAvailable, `now` epoch milliseconds (default Date.now()).
|
||
* @returns {Array<{id: string, title: string, message: string, label: string, url: string}>}
|
||
*/
|
||
function selectSecurityNotices(notices, opts = {}) {
|
||
if (!Array.isArray(notices)) return [];
|
||
const available = opts.available && typeof opts.available === 'object' ? opts.available : {};
|
||
const seenSource = opts.seen;
|
||
const seen = new Set(
|
||
seenSource && typeof seenSource !== 'string' && typeof seenSource[Symbol.iterator] === 'function'
|
||
? Array.from(seenSource, String)
|
||
: []
|
||
);
|
||
const now = typeof opts.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now();
|
||
const lang = opts.lang === 'zh-CN' ? 'zh-CN' : 'en';
|
||
const taken = new Set();
|
||
const shown = [];
|
||
for (const entry of notices) {
|
||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||
const id = securityNoticeText(entry.id);
|
||
const title = securityNoticeText(entry.title);
|
||
const message = securityNoticeText(entry.message);
|
||
const link = entry.link && typeof entry.link === 'object' ? entry.link : null;
|
||
const label = securityNoticeText(link?.label);
|
||
const url = typeof link?.url === 'string' ? link.url.trim() : '';
|
||
const published = securityNoticeDay(entry.published);
|
||
const expires = securityNoticeDay(entry.expires);
|
||
if (!id || !message || !label || !SECURITY_NOTICE_URL_PATTERN.test(url) || !published || !expires) continue;
|
||
if (entry.title !== undefined && !title) continue;
|
||
if (entry.cli !== undefined && (typeof entry.cli !== 'string' || !CLI_ID_PATTERN.test(entry.cli))) continue;
|
||
if (taken.has(id)) continue;
|
||
taken.add(id);
|
||
if (now < published.start || now > expires.end) continue;
|
||
if (seen.has(id)) continue;
|
||
if (entry.cli !== undefined && available[entry.cli] !== true) continue;
|
||
const localized = lang === 'en' ? null : entry.i18n?.[lang];
|
||
shown.push({
|
||
id,
|
||
title: securityNoticeText(localized?.title) || title,
|
||
message: securityNoticeText(localized?.message) || message,
|
||
label: securityNoticeText(localized?.label) || label,
|
||
url,
|
||
});
|
||
}
|
||
return shown;
|
||
}
|
||
|
||
/**
|
||
* Which surface carries each selected notice on this page load, decided together with
|
||
* the once-per-release update toast (checkWhatsNew in settings-ui.js) so a browser gets
|
||
* ONE notification, never a card and a toast for the same notice.
|
||
*
|
||
* - The update toast shows exactly as checkWhatsNew always decided: this browser saw an
|
||
* older version (`seenVersion` set and different), and `notes` are this version's with
|
||
* at least one highlight. Then every selected notice rides INSIDE it (`updateNotices`)
|
||
* and no standalone card is shown.
|
||
* - Otherwise (a fresh install, a reload on the same version, notes missing or empty)
|
||
* each notice gets its own card (`cards`).
|
||
* - `recordVersion`: store `runningVersion` as seen, as checkWhatsNew always did: on a
|
||
* fresh install (so it is not greeted with "what's new") and when the toast shows.
|
||
* Notes that are missing or empty record nothing, so the next load tries again.
|
||
*
|
||
* @param {{seenVersion?: string|null, runningVersion: string, notes?: object|null, notices?: Array<object>}} state
|
||
* `notices` is the output of selectSecurityNotices().
|
||
* @returns {{recordVersion: boolean, update: boolean, updateNotices: Array<object>, cards: Array<object>}}
|
||
*/
|
||
function planStartupNotices(state = {}) {
|
||
const seenVersion = typeof state.seenVersion === 'string' && state.seenVersion ? state.seenVersion : null;
|
||
const runningVersion = state.runningVersion;
|
||
const notices = Array.isArray(state.notices) ? state.notices : [];
|
||
const notes = state.notes;
|
||
const update = Boolean(
|
||
seenVersion &&
|
||
seenVersion !== runningVersion &&
|
||
notes &&
|
||
notes.version === runningVersion &&
|
||
Array.isArray(notes.highlights) &&
|
||
notes.highlights.length > 0
|
||
);
|
||
return {
|
||
recordVersion: !seenVersion || update,
|
||
update,
|
||
updateNotices: update ? notices.slice() : [],
|
||
cards: update ? [] : notices.slice(),
|
||
};
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.CodemanSecurityNotices = {
|
||
select: selectSecurityNotices,
|
||
plan: planStartupNotices,
|
||
// The same https-only rule select() applies, for the card's link button to check again.
|
||
urlOk: (url) => typeof url === 'string' && SECURITY_NOTICE_URL_PATTERN.test(url),
|
||
};
|
||
}
|