Files
Codeman/src/web/public/tab-rail-resize.js
T
Rounak DattaandClaude Opus 5 abf1d1f1ca fix(terminal): the PTY and the browser terminal must never disagree about size
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen".
The screenshot is not a dropped frame or a frozen renderer — it is arithmetic.
Claude Code's TUI wraps its frame at the width the PTY reported and erases the
previous frame by walking the cursor up the rows it believes that frame took. A
browser terminal of a different width makes each logical line occupy more
physical rows than Ink counted, so `eraseLines(n)` clears too few and the new
frame paints over rows nothing erased: doubled lines, and short tool summaries
sitting inside longer prose rows with the prose's tail still visible.

Reproduced against this repo's own xterm before changing anything — a 120-column
PTY against a 62-column terminal renders every wrapped line twice. `test/
terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching
widths beside it, so the assertion cannot be satisfied by code that fixes
nothing.

Four ways the two drifted apart, none of them observable from either end:

1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every
   server-facing path reported those floored at 40x10. Measured in Chrome at
   430px: font size 44 proposed 13 columns, the server was told 40, and xterm
   stayed at 13. Three call sites each did their own fit-then-floor, and two
   re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly
   there, so the container had moved.
2. `throttledResize` (keyboard up) and `sendResize` (session detached into its
   own window) reflowed locally and withheld only the SIGWINCH. That is the one
   combination that cannot be right: a reflow nothing is rendering for buys
   nothing and costs correctness. Both now withhold everything, and the
   keyboard's settle timer still sends the one resize that stops the PTY going
   stale.
3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry
   change — and told the server nothing at all, so raising the font on a phone
   left the CLI wrapping at the old column count.
4. `Session.resize` DECLINES a small-viewport request while a desktop connection
   holds an active sizing claim, and said nothing, because resize was write-only.

`syncTerminalGeometry()` is now the one function that may change the terminal's
size: it fits, floors and applies as a single step, so the numbers xterm holds
are the numbers the server is told. A test sweeps every module for a bare
`fit()` on the main terminal, and finds exactly one — the owner's own.

For (4) the client cannot win, so it is told the truth instead: both transports
answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket,
the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal
that keeps a shape the PTY refused does not render "too narrow", it renders
garbled. Adopting can leave the pane wider than the screen and the container is
`overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as
long as the mismatch lasts: correct-and-reachable beats correct-and-clipped
beats garbled. That rule sets both overflow axes and its own `touch-action`
because mobile.css loads later and sets `.terminal-container { overflow:
visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y
computing to `auto` and hand the browser a vertical scroll container the
terminal's touch handler knows nothing about.

Verified in Chrome at 430px against a live server, with a desktop client holding
the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y:
hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own
vertical scrolling. The pre-fix build was measured in the same harness for the
control.

Two things this deliberately does not do. It does not change who owns the pane
size — the desktop still wins, and `_startMobileResizeRetry` still takes it back
once that goes idle. And `throttledResize` still holds the PTY's shape for the
whole keyboard animation rather than sending a SIGWINCH per step; that decision
predates this and was not re-tested here.

Also in this commit, Ark0N's third-pass review items on #431:

- The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both
  used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes`
  (32MB) — the largest body the frontend asks for anywhere. One carried no
  deadline at all and the other got the 15s tail budget. Both now take the
  full-history budget.
- A `?full=1` capture that outruns its deadline falls back to the bounded tail.
  The pane is blanked before that fetch, so an abort used to leave a black
  rectangle, discard the queued live output and never reach `_connectWs`. A
  failed load now still opens the socket, says one dim line where the content
  would have been, and clears the tab's spinner — which nothing did, so a failed
  select left `aria-busy="true"` set forever.
- `_wsOutputGapSession` is cleared at the repaint that settles it, not in a
  `finally` that also ran on the catch. A reconcile that threw, or hit the new
  deadline — the flaky link the marker exists for — dropped the gap with nothing
  to retry it. `ws.onopen` no longer clears it up front either.
- The replay-clear invariant is pinned in the gate, which is the drift this PR
  exists to fix: `_resetTerminalForReplay` must be a queued write and nothing
  else, and no module may blank the terminal with a `clear()+reset()` pair.
- `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local
  first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was
  never declared, and that is the one function in the app that must not throw.
- panels-ui's two kill-all clears route through the same helper, and the
  xterm-version guard's comment says "resolved lockfile version" rather than
  "dependency RANGE", which is what it has pinned since the last round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:07:33 +05:30

372 lines
16 KiB
JavaScript

/**
* @fileoverview Accessible, device-local vertical session-rail sizing.
*
* Pointer + keyboard resizing for the vertical tab rail (`tabOrientation:
* 'vertical'`), with a preferred width persisted per device (`tabRailWidth`)
* and re-clamped against the viewport on resize. During a drag it takes
* ownership of terminal refits (`_tabRailResizeOwnsObserver` suppresses
* terminal-ui's throttled ResizeObserver) and performs ONE settle-time resize,
* reverting per-frame below 40 columns so the PTY is never thrashed.
*
* @dependency CodemanApp (app.js) - methods attach to its prototype
* @dependency CodemanTabRail (constants.js) - width policy (resolveWidth, bounds)
* @loadorder 6.5 (after app.js, before terminal-ui.js)
*/
Object.assign(CodemanApp.prototype, {
_getTabRailMinimumTerminalWidth() {
const cellWidth = this.terminal?._core?._renderService?.dimensions?.css?.cell?.width;
if (Number.isFinite(cellWidth) && cellWidth > 0) return Math.ceil(cellWidth * 40 + 24);
return 420;
},
_getTabRailBounds() {
const main = document.querySelector('.main');
return {
viewportWidth: window.innerWidth,
mainWidth: main?.clientWidth || window.innerWidth,
minTerminalWidth: this._getTabRailMinimumTerminalWidth(),
};
},
_getCurrentTabRailWidth() {
const fromCss = Number.parseFloat(document.documentElement.style.getPropertyValue('--tab-rail-width'));
if (Number.isFinite(fromCss)) return fromCss;
const measured = document.getElementById('tabRail')?.getBoundingClientRect?.().width;
return Number.isFinite(measured) && measured > 0 ? measured : window.CodemanTabRail?.DEFAULT_WIDTH || 256;
},
readTabRailWidthSetting() {
const select = document.getElementById('appSettingsTabRailWidth');
if (!select) return this._getCurrentTabRailWidth();
if (select.value === 'custom') return Number(select.dataset.currentWidth) || this._getCurrentTabRailWidth();
return Number(select.value) || window.CodemanTabRail?.DEFAULT_WIDTH || 256;
},
syncTabRailWidthSetting(width) {
const select = document.getElementById('appSettingsTabRailWidth');
if (!select) return;
const rounded = Math.round(width);
select.dataset.currentWidth = String(rounded);
const preset = select.querySelector(`option[value="${rounded}"]`);
if (preset) {
select.value = String(rounded);
return;
}
const custom = select.querySelector('option[value="custom"]');
if (custom) custom.textContent = `Custom (${rounded}px)`;
select.value = 'custom';
},
_setTabRailWidth(width) {
const policy = window.CodemanTabRail;
if (!policy) return 256;
const preferred = policy.resolveWidth({ width });
const bounds = this._getTabRailBounds();
const resolved = policy.resolveWidth({ width: preferred, ...bounds });
const effectiveMax = policy.resolveWidth({ width: policy.MAX_WIDTH, ...bounds });
const root = document.documentElement;
root.style.setProperty('--tab-rail-width', `${resolved}px`);
const wasCompact = root.classList.contains('tab-rail-compact');
const compact = resolved < 240;
root.classList.toggle('tab-rail-compact', compact);
// Second, softer threshold, CSS-only: a detailed row carries two stamps and
// below ~288px the created one ellipsizes to "CREA…", which says nothing.
// It is dropped there instead, leaving the state duration (the number the
// list is ordered by) and its pill intact. No re-render — unlike the rows
// themselves, this is a display toggle on markup that is already there.
root.classList.toggle('tab-rail-tight', resolved < 288);
if (wasCompact !== compact) {
// The folder line is owned by applyTabWrapSettings(), whose railRich
// input reads the compact class this function just toggled — without
// re-running it, a rich rail dragged below 240px kept emitting folder
// rows (and, for a stored width < 240, kept them across reloads: the
// boot-time wrap pass runs before this function first applies the
// class). It re-renders only when the folder flag actually flipped, so
// cover the flip-without-folder-change case (a simple-detail rail
// crossing 240px still changes the row-action affordance) without
// rendering twice.
const prevTall = this._tallTabsEnabled;
this.applyTabWrapSettings?.();
const wrapRendered = prevTall !== undefined && this._tallTabsEnabled !== prevTall;
if (!wrapRendered) this._fullRenderSessionTabs?.();
}
const handle = document.getElementById('tabRailResizeHandle');
if (handle) {
handle.setAttribute('aria-valuemax', String(effectiveMax));
handle.setAttribute('aria-valuenow', String(resolved));
}
this.syncTabRailWidthSetting(preferred);
return resolved;
},
_persistTabRailWidth(width) {
const settings = this.loadAppSettingsFromStorage();
if (settings.tabRailWidth === width) return;
settings.tabRailWidth = width;
this.saveAppSettingsToStorage(settings);
},
_claimTabRailResize() {
this._tabRailResizeOwnsObserver = true;
clearTimeout(this._tabRailResizeWatchdog);
clearTimeout(this._tabRailReleaseTimer);
if (this._tabRailReleaseRaf) cancelAnimationFrame(this._tabRailReleaseRaf);
this._armTabRailResizeWatchdog();
if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf);
if (this._resizeTimeout) clearTimeout(this._resizeTimeout);
this._resizeRaf = null;
this._resizeTimeout = null;
},
_armTabRailResizeWatchdog() {
this._tabRailResizeWatchdog = setTimeout(() => {
this._tabRailResizeWatchdog = null;
if (document.body.classList.contains('tab-rail-resizing')) {
this._armTabRailResizeWatchdog();
return;
}
this._tabRailResizeOwnsObserver = false;
}, 1000);
},
_releaseTabRailResize() {
clearTimeout(this._tabRailResizeWatchdog);
this._tabRailResizeWatchdog = null;
const release = () => {
clearTimeout(this._tabRailReleaseTimer);
this._tabRailReleaseTimer = null;
this._tabRailReleaseRaf = null;
this._tabRailResizeOwnsObserver = false;
};
if (typeof requestAnimationFrame === 'function') {
this._tabRailReleaseRaf = requestAnimationFrame(() => {
this._tabRailReleaseRaf = requestAnimationFrame(release);
});
this._tabRailReleaseTimer = setTimeout(release, 250);
} else release();
},
_scheduleTabRailSettle(effective, preferred = effective) {
clearTimeout(this._tabRailSettleTimer);
this._tabRailSettleTimer = setTimeout(async () => {
this._tabRailSettleTimer = null;
this._persistTabRailWidth(preferred);
try {
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
else this.syncTerminalGeometry?.();
this._updateConnectionLinesImmediate?.();
} catch (error) {
console.warn('Failed to resize terminal after rail resize:', error);
} finally {
this._releaseTabRailResize();
}
}, 150);
},
/**
* The width a rail gets when the user has never picked one.
*
* Detailed rows carry a third line ("created 3d ago · working 12m" plus a
* status pill) and at 256px that line ellipsizes before it is finished — the
* same reason the rich SIDEBAR is 300px and the simple one 260px. 320px is
* the existing Wide preset, so a fresh detailed rail lands on a named choice
* rather than reading "Custom" in the settings select.
*
* Only the DEFAULT moves: a width the user has actually chosen (stored) is
* never overridden, and dragging the rail narrower is never fought — below
* 240px the rows drop back to simple ones on their own.
*/
_defaultTabRailWidth() {
const rich = document.documentElement.dataset.tabRailDetail !== 'simple';
if (rich) return window.CodemanTabRail?.RICH_DEFAULT_WIDTH ?? 320;
return window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
},
applyTabRailWidth(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const requested = settings.tabRailWidth ?? this._defaultTabRailWidth();
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
if (options.settle) this._claimTabRailResize();
const resolved = this._setTabRailWidth(preferred);
if (options.persist !== false && requested !== preferred) this._persistTabRailWidth(preferred);
if (options.settle) this._scheduleTabRailSettle(resolved, preferred);
return resolved;
},
_applyTabRailPointerWidth(clientX) {
const main = document.querySelector('.main');
if (!main) return this._getCurrentTabRailWidth();
const previous = this._getCurrentTabRailWidth();
const width = this._setTabRailWidth(clientX - main.getBoundingClientRect().left);
let proposed = null;
try {
proposed = this.fitAddon?.proposeDimensions?.();
} catch {}
return proposed && proposed.cols < 40 ? this._setTabRailWidth(previous) : width;
},
_queueTabRailPointerWidth(clientX) {
this._tabRailPendingClientX = clientX;
if (this._tabRailPointerRaf) return;
this._tabRailPointerRaf = requestAnimationFrame(() => {
this._tabRailPointerRaf = null;
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
});
},
_finishTabRailDrag(handle, pointerId) {
if (!document.body.classList.contains('tab-rail-resizing')) return;
if (this._tabRailPointerRaf) {
cancelAnimationFrame(this._tabRailPointerRaf);
this._tabRailPointerRaf = null;
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
}
document.body.classList.remove('tab-rail-resizing');
const shield = document.getElementById('tabRailResizeShield');
if (shield) shield.hidden = true;
try {
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
} catch {}
const preferred = window.CodemanTabRail?.resolveWidth({ width: this._tabRailDragWidth }) ?? this._tabRailDragWidth;
this._scheduleTabRailSettle(this._tabRailDragWidth || this._getCurrentTabRailWidth(), preferred);
},
_onTabRailKeyDown(event) {
const width = window.CodemanTabRail?.resolveKeyboardWidth({
key: event.key,
shiftKey: event.shiftKey,
currentWidth: this._getCurrentTabRailWidth(),
defaultWidth: this._defaultTabRailWidth?.(),
...this._getTabRailBounds(),
});
if (width === null || width === undefined) return;
event.preventDefault();
this._claimTabRailResize();
const effective = this._setTabRailWidth(width);
this._scheduleTabRailSettle(effective, window.CodemanTabRail.resolveWidth({ width }));
},
initTabRailResize() {
const handle = document.getElementById('tabRailResizeHandle');
if (!handle || handle.dataset.ready === '1') return;
handle.dataset.ready = '1';
this.applyTabRailWidth();
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
this.closeTabRailActionMenu();
this._claimTabRailResize();
this._tabRailDragWidth = this._getCurrentTabRailWidth();
this._tabRailPendingClientX = event.clientX;
document.body.classList.add('tab-rail-resizing');
const shield = document.getElementById('tabRailResizeShield');
if (shield) shield.hidden = false;
try {
handle.setPointerCapture(event.pointerId);
} catch {
document.body.classList.remove('tab-rail-resizing');
if (shield) shield.hidden = true;
this._releaseTabRailResize();
}
});
handle.addEventListener('pointermove', (event) => {
if (handle.hasPointerCapture?.(event.pointerId)) this._queueTabRailPointerWidth(event.clientX);
});
handle.addEventListener('pointerup', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('pointercancel', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('lostpointercapture', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('keydown', (event) => this._onTabRailKeyDown(event));
handle.addEventListener('dblclick', (event) => {
event.preventDefault();
this._claimTabRailResize();
// Rich-aware: resetting a detailed rail to 256 would land it below the
// 288px tight threshold and silently drop the created stamp.
const preferred = this._defaultTabRailWidth?.() ?? (window.CodemanTabRail?.DEFAULT_WIDTH || 256);
const effective = this._setTabRailWidth(preferred);
this._scheduleTabRailSettle(effective, preferred);
});
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
this.applyTabOrientation?.();
this.applyTabRailWidth({ persist: false });
}, 100);
});
},
closeTabRailActionMenu(options = {}) {
const menu = document.querySelector('.tab-rail-action-menu');
const trigger = this._tabRailActionMenuTrigger;
menu?.remove();
if (this._tabRailActionMenuOutside) {
document.removeEventListener('pointerdown', this._tabRailActionMenuOutside, true);
this._tabRailActionMenuOutside = null;
}
if (this._tabRailActionMenuViewport) {
window.removeEventListener('resize', this._tabRailActionMenuViewport);
this._tabRailActionMenuViewport = null;
}
this._tabRailActionMenuTrigger = null;
if (options.restoreFocus) trigger?.focus?.();
},
openTabRailActionMenu(event, sessionId) {
event.preventDefault();
event.stopPropagation();
this.closeTabRailActionMenu();
const trigger = event.currentTarget;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Session actions');
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
];
for (const action of actions) {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.className) button.className = action.className;
button.addEventListener('click', () => {
this.closeTabRailActionMenu();
action.run();
});
menu.appendChild(button);
}
document.body.appendChild(menu);
const rect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(rect.right - menuRect.width, window.innerWidth - menuRect.width - 8))}px`;
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`;
this._tabRailActionMenuTrigger = trigger;
this._tabRailActionMenuOutside = (pointerEvent) => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== trigger) this.closeTabRailActionMenu();
};
document.addEventListener('pointerdown', this._tabRailActionMenuOutside, true);
this._tabRailActionMenuViewport = () => this.closeTabRailActionMenu();
window.addEventListener('resize', this._tabRailActionMenuViewport, { once: true });
menu.addEventListener('keydown', (keyEvent) => {
const buttons = [...menu.querySelectorAll('button')];
const index = buttons.indexOf(document.activeElement);
if (keyEvent.key === 'Escape') {
keyEvent.preventDefault();
this.closeTabRailActionMenu({ restoreFocus: true });
} else if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
keyEvent.preventDefault();
const direction = keyEvent.key === 'ArrowDown' ? 1 : -1;
buttons[(index + direction + buttons.length) % buttons.length]?.focus();
}
});
menu.querySelector('button')?.focus();
},
});