Merge master (PR #112 terminal pane-buffer rework) into v1-readiness-hardening

Conflict in src/web/public/app.js selectSession: combined #112's
_clearTerminalLoadState cleanup on stale select with #113's
{success,data} envelope unwrap of the terminal fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 03:17:12 +02:00
co-authored by Claude Opus 4.8
13 changed files with 883 additions and 72 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<h2 align="center">The missing control plane for AI coding agents</h2>
<p align="center">
<em>Agent Visualization &bull; Zero-Lag Input &bull; Autonomous Orchestrator &bull; Respawn Controller &bull; Mobile-First UI &bull; Hardened Security</em>
<em>Agent Visualization &bull; Zero-Lag Input &bull; Mobile-First UI &bull; Hardened Security</em>
</p>
<p align="center">
+12
View File
@@ -192,6 +192,12 @@ export interface TerminalMultiplexer extends EventEmitter {
*/
getAttachArgs(muxName: string): string[];
/** Pin a mux window so client attaches do not automatically dictate its size. */
setManualWindowSize?(muxName: string): boolean;
/** Explicitly resize a mux window after Codeman accepts a terminal resize. */
resizeWindow?(muxName: string, cols: number, rows: number): boolean;
// ========== Availability ==========
/** Check if the multiplexer binary is available on the system */
@@ -205,4 +211,10 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
/** Capture a pane's current tmux buffer with ANSI escape codes preserved. */
capturePaneBuffer?(muxName: string, paneTarget: string): string | null;
/** Capture the active pane's current tmux buffer with ANSI escape codes preserved. */
captureActivePaneBuffer?(muxName: string): string | null;
}
+9 -1
View File
@@ -1004,7 +1004,12 @@ export class Session extends EventEmitter {
}
// Attach to the mux session via PTY
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
// Prevent tmux from letting the newest browser attach dictate global window
// size; accepted Codeman resize events update it explicitly below.
mux.setManualWindowSize?.(this._muxSession!.muxName);
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default).
// MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the
// default server, always fails for our socketed sessions, and silently falls back to 120x40.
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
@@ -2057,6 +2062,9 @@ export class Session extends EventEmitter {
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
this._ptyCols = cols;
this._ptyRows = rows;
if (this._mux && this._muxSession) {
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
}
this.ptyProcess.resize(cols, rows);
}
}
+384 -6
View File
@@ -164,6 +164,280 @@ export function parsePaneList(output: string): Map<string, number> {
return result;
}
/**
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
* Prefers the active pane and falls back to the first valid pane.
*/
export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): string | null {
if (!isValidMuxName(muxName)) {
return null;
}
if (paneTarget === undefined || paneTarget === 'active') {
return muxName;
}
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
return null;
}
return `${muxName}.${paneTarget}`;
}
/**
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
*/
export function resolveActivePaneTarget(output: string): string | null {
for (const line of output.split('\n')) {
const sep = line.indexOf(':');
if (sep === -1) continue;
const paneId = line.slice(0, sep).trim();
const active = line.slice(sep + 1).trim();
if (paneId && active === '1') return paneId;
}
return null;
}
type GraphemeSegmenter = {
segment(input: string): Iterable<{ segment: string }>;
};
const GRAPHEME_SEGMENTER: GraphemeSegmenter | null = (() => {
try {
const Segmenter = (
Intl as typeof Intl & {
Segmenter?: new (locale?: string, options?: { granularity: 'grapheme' }) => GraphemeSegmenter;
}
).Segmenter;
return Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : null;
} catch {
return null;
}
})();
function findEscapeEnd(text: string, start: number): number {
const type = text[start + 1];
if (type === '[') {
for (let i = start + 2; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) return i;
}
return text.length - 1;
}
if (type === ']') {
for (let i = start + 2; i < text.length; i++) {
if (text.charCodeAt(i) === 0x07) return i;
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
}
return text.length - 1;
}
if (type === 'P' || type === '^' || type === '_' || type === 'X') {
for (let i = start + 2; i < text.length; i++) {
if (text.charCodeAt(i) === 0x07) return i;
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
}
return text.length - 1;
}
return Math.min(start + 1, text.length - 1);
}
function sanitizePaneLineStyles(line: string): string {
let result = '';
for (let i = 0; i < line.length; i++) {
if (line[i] !== '\x1b') {
result += line[i];
continue;
}
const end = findEscapeEnd(line, i);
const sequence = line.slice(i, end + 1);
if (isSgrSequence(sequence)) {
result += sequence;
}
i = end;
}
return result;
}
function isSgrSequence(sequence: string): boolean {
return (
sequence.length >= 3 &&
sequence.charCodeAt(0) === 27 &&
sequence[1] === '[' &&
sequence.endsWith('m') &&
/^[0-9;:]*$/.test(sequence.slice(2, -1))
);
}
function isZeroWidthCodePoint(codePoint: number): boolean {
return (
codePoint === 0x00ad ||
codePoint === 0x034f ||
codePoint === 0x061c ||
codePoint === 0x115f ||
codePoint === 0x1160 ||
codePoint === 0x17b4 ||
codePoint === 0x17b5 ||
codePoint === 0x180e ||
codePoint === 0x200b ||
codePoint === 0x200c ||
codePoint === 0x200d ||
codePoint === 0x2060 ||
codePoint === 0xfeff ||
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0x0483 && codePoint <= 0x0489) ||
(codePoint >= 0x0591 && codePoint <= 0x05bd) ||
codePoint === 0x05bf ||
(codePoint >= 0x05c1 && codePoint <= 0x05c2) ||
(codePoint >= 0x05c4 && codePoint <= 0x05c5) ||
codePoint === 0x05c7 ||
(codePoint >= 0x0610 && codePoint <= 0x061a) ||
(codePoint >= 0x064b && codePoint <= 0x065f) ||
codePoint === 0x0670 ||
(codePoint >= 0x06d6 && codePoint <= 0x06dc) ||
(codePoint >= 0x06df && codePoint <= 0x06e4) ||
(codePoint >= 0x06e7 && codePoint <= 0x06e8) ||
(codePoint >= 0x06ea && codePoint <= 0x06ed) ||
codePoint === 0x0711 ||
(codePoint >= 0x0730 && codePoint <= 0x074a) ||
(codePoint >= 0x07a6 && codePoint <= 0x07b0) ||
(codePoint >= 0x07eb && codePoint <= 0x07f3) ||
(codePoint >= 0x0816 && codePoint <= 0x0819) ||
(codePoint >= 0x081b && codePoint <= 0x0823) ||
(codePoint >= 0x0825 && codePoint <= 0x0827) ||
(codePoint >= 0x0829 && codePoint <= 0x082d) ||
(codePoint >= 0x0859 && codePoint <= 0x085b) ||
(codePoint >= 0x08d3 && codePoint <= 0x08e1) ||
(codePoint >= 0x08e3 && codePoint <= 0x0902) ||
(codePoint >= 0x093a && codePoint <= 0x093c) ||
codePoint === 0x094d ||
(codePoint >= 0x0951 && codePoint <= 0x0957) ||
(codePoint >= 0x0962 && codePoint <= 0x0963) ||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
);
}
function isWideCodePoint(codePoint: number): boolean {
return (
codePoint >= 0x1100 &&
(codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd))
);
}
function nextGrapheme(text: string, start: number): { value: string; nextIndex: number } {
if (GRAPHEME_SEGMENTER) {
const iterator = GRAPHEME_SEGMENTER.segment(text.slice(start))[Symbol.iterator]();
const next = iterator.next();
if (!next.done && next.value.segment) {
return { value: next.value.segment, nextIndex: start + next.value.segment.length };
}
}
const first = text.codePointAt(start);
if (first === undefined) return { value: '', nextIndex: start + 1 };
let value = String.fromCodePoint(first);
let nextIndex = start + value.length;
while (nextIndex < text.length) {
const codePoint = text.codePointAt(nextIndex);
if (codePoint === undefined || !isZeroWidthCodePoint(codePoint)) break;
const mark = String.fromCodePoint(codePoint);
value += mark;
nextIndex += mark.length;
}
return { value, nextIndex };
}
function terminalCellWidth(grapheme: string): number {
let hasVisible = false;
let hasWide = false;
for (let i = 0; i < grapheme.length; i++) {
const codePoint = grapheme.codePointAt(i);
if (codePoint === undefined) continue;
if (codePoint > 0xffff) i++;
if (isZeroWidthCodePoint(codePoint) || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
continue;
}
hasVisible = true;
if (isWideCodePoint(codePoint)) hasWide = true;
}
if (!hasVisible) return 0;
return hasWide ? 2 : 1;
}
function truncatePaneLineByVisibleColumns(line: string, maxColumns: number): string {
let result = '';
let visibleColumns = 0;
let sawSgr = false;
for (let i = 0; i < line.length; i++) {
if (line[i] === '\x1b') {
const end = findEscapeEnd(line, i);
const sequence = line.slice(i, end + 1);
if (isSgrSequence(sequence)) {
result += sequence;
sawSgr = true;
}
i = end;
continue;
}
const grapheme = nextGrapheme(line, i);
const width = terminalCellWidth(grapheme.value);
if (width === 0) {
result += grapheme.value;
} else if (visibleColumns + width <= maxColumns) {
result += grapheme.value;
visibleColumns += width;
} else {
break;
}
i = grapheme.nextIndex - 1;
if (visibleColumns >= maxColumns) {
continue;
}
}
if (sawSgr) {
result += '\x1b[0m';
}
return result;
}
export function formatPaneSnapshot(
lines: string[],
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
): string {
const cols = Math.max(1, geometry.cols);
const paintCols = Math.max(1, cols - 1);
const rows = Math.max(1, geometry.rows);
const parts: string[] = [];
for (let row = 0; row < Math.min(lines.length, rows); row++) {
const safeLine = truncatePaneLineByVisibleColumns(sanitizePaneLineStyles(lines[row]), paintCols);
parts.push(`\x1b[${row + 1};1H${safeLine}`);
}
const cursorX = Math.max(0, Math.min(cols - 1, geometry.cursorX));
const cursorY = Math.max(0, Math.min(rows - 1, geometry.cursorY));
parts.push(`\x1b[${cursorY + 1};${cursorX + 1}H`);
return parts.join('');
}
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
@@ -175,6 +449,10 @@ function isValidMuxName(name: string): boolean {
return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
}
function isValidTerminalDimension(value: number): boolean {
return Number.isSafeInteger(value) && value > 0 && value <= 1000;
}
/**
* Validates that a path contains only safe characters.
* Prevents command injection via malformed paths.
@@ -682,14 +960,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env };
delete cleanEnv.TMUX;
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
// blips do not poison tmux's long-lived getcwd state.
// Create the session on the dedicated socket (${this.tmux()} = `tmux -L <socket>`),
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
// pane cd's into workingDir below via respawn-pane.
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
cwd: TMUX_LAUNCH_CWD,
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
env: cleanEnv,
});
this.resizeWindow(muxName, 120, 40);
// Set remain-on-exit now that the server is running — must be before respawn-pane
try {
@@ -1689,8 +1970,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
/**
* Capture the current buffer of a specific pane.
* Returns the pane content with ANSI escape codes preserved.
* Capture the current visible text and SGR styles of a specific pane.
*
* `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style
* codes are preserved, while cursor/erase/scroll-region controls are stripped
* before rows are repainted at absolute positions in browser xterm.
*/
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
if (IS_TEST_MODE) return '';
@@ -1706,16 +1990,67 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
}).replace(/\n+$/g, '');
try {
const cursor = execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim();
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
if (
Number.isFinite(cursorX) &&
Number.isFinite(cursorY) &&
Number.isFinite(cols) &&
Number.isFinite(rows) &&
cursorX >= 0 &&
cursorY >= 0 &&
cols > 0 &&
rows > 0
) {
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
}
} catch (cursorErr) {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
}
return buffer;
} catch (err) {
console.error('[TmuxManager] Failed to capture pane buffer:', err);
return null;
}
}
/**
* Capture the active pane for a tmux session.
*
* Pane ids are not stable across respawns or restores, so callers should not
* assume the first pane remains `%0`.
*/
captureActivePaneBuffer(muxName: string): string | null {
if (IS_TEST_MODE) return '';
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
return null;
}
try {
const output = execSync(`${this.tmux()} list-panes -t ${shellescape(muxName)} -F '#{pane_id}:#{pane_active}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const target = resolveActivePaneTarget(output);
return target ? this.capturePaneBuffer(muxName, target) : null;
} catch (err) {
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
return null;
}
}
/**
* Start piping pane output to a file using tmux pipe-pane.
* Only pipes output direction (-O) to avoid echoing input.
@@ -1785,6 +2120,49 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
}
setManualWindowSize(muxName: string): boolean {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in setManualWindowSize:', muxName);
return false;
}
try {
execSync(`${this.tmux()} set-window-option -t ${shellescape(muxName)} window-size manual`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
return true;
} catch (err) {
console.error('[TmuxManager] Failed to set manual window size:', err);
return false;
}
}
resizeWindow(muxName: string, cols: number, rows: number): boolean {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in resizeWindow:', muxName);
return false;
}
if (!isValidTerminalDimension(cols) || !isValidTerminalDimension(rows)) {
console.error('[TmuxManager] Invalid resize dimensions:', { cols, rows });
return false;
}
// Fire-and-forget: this runs on the interactive resize path (WS {t:'z'} and
// HTTP /resize), so use a non-blocking exec — a slow/hung tmux must not stall
// the Fastify event loop while other sessions' input/SSE are served. The sole
// caller (Session.resize) ignores the result, and under `window-size manual`
// the subsequent ptyProcess.resize is subordinate to this authoritative size.
exec(
`${this.tmux()} resize-window -t ${shellescape(muxName)} -x ${cols} -y ${rows}`,
{ timeout: EXEC_TIMEOUT_MS },
(err) => {
if (err) console.error('[TmuxManager] Failed to resize tmux window:', err);
}
);
return true;
}
isAvailable(): boolean {
return TmuxManager.isTmuxAvailable();
}
+177 -32
View File
@@ -314,6 +314,7 @@ class CodemanApp {
this._initGeneration = 0; // dedup concurrent handleInit calls
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
this._selectGeneration = 0; // cancel stale selectSession loads
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
@@ -416,6 +417,8 @@ class CodemanApp {
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
this._loadBufferQueue = null; // queued SSE events during buffer load
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
// Flicker filter state (buffers output after screen clears)
this.flickerFilterBuffer = '';
@@ -486,7 +489,7 @@ class CodemanApp {
// If stale, cleans up buffer-loading state and returns true.
_isStaleSelect(selectGen) {
if (selectGen !== this._selectGeneration) {
if (this._isLoadingBuffer) this._finishBufferLoad();
if (this._isLoadingBuffer) this._finishBufferLoad(selectGen);
this._restoringFlushedState = false;
return true;
}
@@ -649,6 +652,7 @@ class CodemanApp {
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
this._scheduleTerminalRepaint();
});
this.terminal.loadAddon(this._webglAddon);
console.log('[CRASH-DIAG] WebGL renderer enabled');
@@ -679,7 +683,7 @@ class CodemanApp {
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
this._scheduleTerminalRepaint();
}
});
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
@@ -698,6 +702,22 @@ class CodemanApp {
this._webglLongTaskObserver = null;
}
/**
* Repaint the full terminal viewport after a renderer swap (WebGL → canvas/DOM).
* Scheduled on the next frame so it lands after the addon teardown settles, and
* debounced so the context-loss and long-task fallback paths can't double-fire.
* No-ops safely if the terminal isn't ready.
*/
_scheduleTerminalRepaint() {
if (this._terminalRepaintScheduled) return;
this._terminalRepaintScheduled = true;
const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => setTimeout(cb, 0);
raf(() => {
this._terminalRepaintScheduled = false;
try { this.terminal?.refresh(0, this.terminal.rows - 1); } catch {}
});
}
_disableWebGLSticky(reason) {
try {
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
@@ -2039,6 +2059,7 @@ class CodemanApp {
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// Preserve local echo overlay text across SSE reconnect — just hide until
@@ -2281,7 +2302,7 @@ class CodemanApp {
renderSessionTabs() {
// Don't re-render while user is typing in the inline rename input
if (this._activeRename) return;
if (this._inlineRenameActive) return;
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
}
@@ -2299,6 +2320,45 @@ class CodemanApp {
}
}
_setTerminalLoadState(sessionId, selectGen, phase) {
this.terminalLoadStates.set(sessionId, { generation: selectGen, phase });
this._updateTerminalLoadTab(sessionId);
}
_clearTerminalLoadState(sessionId, selectGen) {
const state = this.terminalLoadStates.get(sessionId);
if (state && state.generation !== selectGen) return;
this.terminalLoadStates.delete(sessionId);
this._updateTerminalLoadTab(sessionId);
}
_updateTerminalLoadTab(sessionId) {
const tab = this.$('sessionTabs')?.querySelector(`.session-tab[data-id="${sessionId}"]`);
if (!tab) return;
const loadState = this.terminalLoadStates.get(sessionId);
tab.classList.toggle('tab-loading', !!loadState);
if (loadState) {
tab.setAttribute('aria-busy', 'true');
tab.dataset.loadPhase = loadState.phase;
if (!tab.querySelector('.tab-load-spinner')) {
const spinner = document.createElement('span');
spinner.className = 'tab-load-spinner';
spinner.setAttribute('aria-hidden', 'true');
const numberEl = tab.querySelector('.tab-number');
if (numberEl) {
numberEl.insertAdjacentElement('afterend', spinner);
} else {
tab.insertBefore(spinner, tab.firstChild);
}
}
} else {
tab.setAttribute('aria-busy', 'false');
delete tab.dataset.loadPhase;
tab.querySelector('.tab-load-spinner')?.remove();
}
}
_renderSessionTabsImmediate() {
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
@@ -2320,6 +2380,7 @@ class CodemanApp {
const name = this.getSessionName(session);
const taskStats = session.taskStats || { running: 0, total: 0 };
const hasRunningTasks = taskStats.running > 0;
const loadState = this.terminalLoadStates.get(id);
// Update active class
if (isActive && !tab.classList.contains('active')) {
@@ -2328,6 +2389,27 @@ class CodemanApp {
tab.classList.remove('active');
}
tab.classList.toggle('tab-loading', !!loadState);
if (loadState) {
tab.setAttribute('aria-busy', 'true');
tab.dataset.loadPhase = loadState.phase;
if (!tab.querySelector('.tab-load-spinner')) {
const spinner = document.createElement('span');
spinner.className = 'tab-load-spinner';
spinner.setAttribute('aria-hidden', 'true');
const numberEl = tab.querySelector('.tab-number');
if (numberEl) {
numberEl.insertAdjacentElement('afterend', spinner);
} else {
tab.insertBefore(spinner, tab.firstChild);
}
}
} else {
tab.setAttribute('aria-busy', 'false');
delete tab.dataset.loadPhase;
tab.querySelector('.tab-load-spinner')?.remove();
}
// Update alert class
const alertType = this.tabAlerts.get(id);
const wantAction = alertType === 'action';
@@ -2426,7 +2508,7 @@ class CodemanApp {
}
_fullRenderSessionTabs() {
if (this._activeRename) return;
if (this._inlineRenameActive) return;
const container = this.$('sessionTabs');
// Clean up any orphaned dropdowns before re-rendering
@@ -2456,6 +2538,7 @@ class CodemanApp {
const hasRunningTasks = taskStats.running > 0;
const alertType = this.tabAlerts.get(id);
const alertClass = alertType === 'action' ? ' tab-alert-action' : alertType === 'idle' ? ' tab-alert-idle' : '';
const loadState = this.terminalLoadStates.get(id);
// Get minimized subagents for this session
const minimizedAgents = this.minimizedSubagents.get(id);
@@ -2467,8 +2550,9 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${this.detachedSessions.has(id) ? ' detached' : ''}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.selectSession('${escapeHtml(id)}', { forceReload: true })" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
@@ -2516,7 +2600,7 @@ class CodemanApp {
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
e.preventDefault();
const sessionId = tabs[currentIndex].dataset.id;
this.selectSession(sessionId);
this.selectSession(sessionId, { forceReload: true });
return;
}
@@ -2761,6 +2845,7 @@ class CodemanApp {
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
// Abort any in-flight chunkedTerminalWrite from the previous session.
// Without this, old rAF-scheduled chunks continue writing stale data
// into the terminal, interleaving with the new session's buffer.
@@ -2811,15 +2896,31 @@ class CodemanApp {
}
}
async selectSession(sessionId) {
_resetTerminalForReplay() {
this.terminal.reset();
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
}
async selectSession(sessionId, options = {}) {
// If this session is popped out into its own window, raise that window
// instead of showing it inline (focus-on-click for detached tabs).
// instead of showing it inline (focus-on-click for detached tabs). If we
// owned a now-closed window, _raiseDetached re-docks and returns false so
// we fall through and load it inline.
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
// Raise the popup instead of showing inline. If we owned a now-closed
// window, _raiseDetached re-docks and returns false so we fall through.
if (this._raiseDetached(sessionId)) return;
}
if (this.activeSessionId === sessionId) return;
const forceReload = options?.forceReload === true;
if (this.activeSessionId === sessionId && !forceReload) return;
if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId);
this._clearTimer('syncWaitTimeout');
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
this.activeSessionId = null;
}
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
// programmatic focus() within the user-gesture call stack (e.g. tab click).
// After the first await the gesture context is lost and focus() is silently
@@ -2832,8 +2933,12 @@ class CodemanApp {
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
const selectGen = ++this._selectGeneration;
this._setTerminalLoadState(sessionId, selectGen, 'resizing');
if (selectGen !== this._selectGeneration) return; // newer tab switch won
if (selectGen !== this._selectGeneration) {
this._clearTerminalLoadState(sessionId, selectGen);
return; // newer tab switch won
}
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
@@ -2905,39 +3010,71 @@ class CodemanApp {
// Without this, SSE events arriving during the fetch() gap compete with
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
const bufferLoadOwner = this._beginBufferLoad(selectGen);
try {
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
// canvas may be at stale dimensions — content would render at wrong width.
if (this.fitAddon) this.fitAddon.fit();
// Also push the new dimensions to the PTY. Without this, codex/codeman
// sees the size that was set the last time the throttled resize handler
// fired (often the size of a different session's container, or the
// initial tmux default). The visible symptom is codex rendering inside
// a small region with empty rows below the status bar.
// sendResize is a no-op on the server when dims haven't changed, so
// calling it every tab switch is cheap.
const dimsChanged = await this.sendResize(sessionId, { forceHttp: true }).catch(() => false);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
// Instant cache restore for IDLE sessions only.
// For busy sessions, the cache is always stale — writing it first causes a
// jarring double-render: stale content appears, then the terminal flashes
// blank and rewrites with fresh data. Skip the cache and write the fresh
// buffer once for a single clean transition.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
if (cachedBuffer && !sessionIsBusy) {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this.terminal.clear();
this.terminal.reset();
await this.chunkedTerminalWrite(cachedBuffer);
if (this._isStaleSelect(selectGen)) return;
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
this._resetTerminalForReplay();
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE');
} else if (sessionIsBusy) {
// Clear stale content immediately — fresh buffer is being fetched
this.terminal.clear();
this.terminal.reset();
this._resetTerminalForReplay();
_crashDiag.log('CACHE_SKIP_BUSY');
}
// Give TUI sessions a short chance to redraw after resize before the
// fresh buffer fetch. Only needed when the resize actually changed
// dimensions (a real SIGWINCH → Ink redraw); a same-size tab switch sent
// no resize, so waiting would just add latency. Shell sessions never need
// it, so terminal content can appear immediately when switching shells.
if (session?.mode !== 'shell' && dimsChanged) {
await new Promise((resolve) => setTimeout(resolve, TUI_REDRAW_SETTLE_MS));
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
}
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
_crashDiag.log('FETCH_START');
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
if (this._isStaleSelect(selectGen)) return;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
const data = (await res.json())?.data ?? {};
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
@@ -2945,18 +3082,23 @@ class CodemanApp {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
// very visible, causing the terminal to flash blank then repaint.
const needsRewrite = data.terminalBuffer !== cachedBuffer;
// Busy sessions skip cache restore and clear the terminal before fetching,
// so they must replay the fetched buffer even when it matches cache.
const needsRewrite = sessionIsBusy || data.terminalBuffer !== cachedBuffer;
if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this.terminal.clear();
this.terminal.reset();
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
this._resetTerminalForReplay();
// Show truncation indicator if buffer was cut
if (data.truncated) {
this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
}
// Use chunked write for large buffers to avoid UI jank
await this.chunkedTerminalWrite(data.terminalBuffer);
if (this._isStaleSelect(selectGen)) return;
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
// Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom();
}
@@ -2970,15 +3112,14 @@ class CodemanApp {
}
} else if (!cachedBuffer) {
// No fresh buffer and no cache — clear any stale content
this.terminal.clear();
this.terminal.reset();
this._resetTerminalForReplay();
}
// Buffer load complete — unblock live SSE writes (queued events are discarded
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
// internally, but if we skipped the write (cache hit or empty), call it here.
if (this._isLoadingBuffer) {
this._finishBufferLoad();
this._finishBufferLoad(bufferLoadOwner);
}
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
@@ -3091,12 +3232,14 @@ class CodemanApp {
_crashDiag.log('FOCUS');
this.terminal.focus();
this.terminal.scrollToBottom();
this.scrollToLastNonEmptyLine();
this._clearTerminalLoadState(sessionId, selectGen);
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
} catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad();
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
this._restoringFlushedState = false;
this._setTerminalLoadState(sessionId, selectGen, 'failed');
console.error('Failed to load session terminal:', err);
}
}
@@ -3126,6 +3269,7 @@ class CodemanApp {
this.projectInsights.delete(sessionId);
this.pendingHooks.delete(sessionId);
this.tabAlerts.delete(sessionId);
this.terminalLoadStates.delete(sessionId);
this.clearCountdownTimers(sessionId);
this.closeSessionLogViewerWindows(sessionId);
this.closeSessionImagePopups(sessionId);
@@ -3286,6 +3430,7 @@ class CodemanApp {
this.sessions.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.terminalLoadStates.clear();
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.respawnStatus = {};
+1
View File
@@ -57,6 +57,7 @@ const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer l
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
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;
+7 -8
View File
@@ -927,9 +927,8 @@ Object.assign(CodemanApp.prototype, {
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return;
// If a previous rename somehow leaked (shouldn't happen, but defends against
// future code paths that throw before cleanup), abort it before starting fresh.
if (this._activeRename) this._activeRename.cancel();
// Prevent tab re-renders from destroying the input while renaming
this._inlineRenameActive = true;
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
@@ -957,14 +956,14 @@ Object.assign(CodemanApp.prototype, {
input.focus();
input.select();
let settled = false;
const finishRename = async ({ commit }) => {
if (settled) return;
settled = true;
if (!this._inlineRenameActive) return; // prevent double-fire
this._inlineRenameActive = false;
this._activeRename = null;
// Aborted (e.g. session was deleted mid-rename): just re-render so any
// ghost DOM left behind is replaced with the canonical tab list.
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
// so any ghost DOM is replaced with the canonical tab list, and skip the
// API call — a cancel must not fire a stale rename PUT.
if (!commit) {
this.renderSessionTabs();
return;
+37
View File
@@ -233,6 +233,7 @@ body {
display: flex;
align-items: center;
gap: 0.35rem;
position: relative;
padding: 0.35rem 0.6rem;
background: transparent;
border: 1px solid transparent;
@@ -265,6 +266,40 @@ body {
outline: none !important;
}
.session-tab.tab-loading::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: -2px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(96, 165, 250, 0.95), transparent);
animation: tab-load-sweep 0.9s linear infinite;
pointer-events: none;
}
.tab-load-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(96, 165, 250, 0.3);
border-top-color: rgba(96, 165, 250, 0.95);
border-radius: 50%;
flex: 0 0 10px;
animation: tab-load-spin 0.7s linear infinite;
}
@keyframes tab-load-spin {
to { transform: rotate(360deg); }
}
@keyframes tab-load-sweep {
0% { transform: translateX(-45%); opacity: 0.35; }
50% { opacity: 1; }
100% { transform: translateX(45%); opacity: 0.35; }
}
/* Tab switch feedback: bright green glow on the newly-active tab */
.session-tab.tab-glow {
animation: tab-glow 0.35s ease-out;
@@ -2070,6 +2105,8 @@ body.solo-mode .btn-lifecycle-log {
will-change: contents;
}
.terminal-container .xterm {
width: 100%;
min-width: 0;
height: 100%;
padding: 0;
}
+112 -20
View File
@@ -12,6 +12,24 @@
* @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js
*/
(function (global) {
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
}
function shouldSuppressTerminalQueryResponse(data) {
return isTerminalQueryResponse(data);
}
global.CodemanTerminalInput = {
isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse,
};
})(window);
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Terminal Setup — xterm.js config and input handling
@@ -164,7 +182,24 @@ Object.assign(CodemanApp.prototype, {
// whitespace) -- if so, xterm handled it and we should not double-send.
// Use a microtask to check after xterm's own handlers have run.
const data = e.data;
const pendingBefore = this._localEchoOverlay?.pendingText || '';
Promise.resolve().then(() => {
if (
this._lastTerminalData?.data === data &&
performance.now() - this._lastTerminalData.time < 100
) {
xtermTextarea.value = '';
return;
}
const pendingAfter = this._localEchoOverlay?.pendingText || '';
if (
this._localEchoEnabled &&
pendingAfter.length > pendingBefore.length &&
pendingAfter.endsWith(data)
) {
xtermTextarea.value = '';
return;
}
// If xterm cleared the textarea, it processed the input -- skip.
const val = xtermTextarea.value;
if (!val || (val.trim() === '' && data !== ' ')) return;
@@ -328,6 +363,7 @@ Object.assign(CodemanApp.prototype, {
'touchmove',
(ev) => {
if (ev.touches.length === 1 && isTouching) {
ev.preventDefault();
didScroll = true;
const touchY = ev.touches[0].clientY;
const delta = touchLastY - touchY; // positive = scroll down
@@ -343,7 +379,7 @@ Object.assign(CodemanApp.prototype, {
}
}
},
{ passive: true }
{ passive: false }
);
container.addEventListener(
@@ -382,6 +418,8 @@ Object.assign(CodemanApp.prototype, {
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
this._chunkedWriteGen = 0;
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
// Handle resize with throttling for performance
this._resizeTimeout = null;
@@ -498,11 +536,15 @@ Object.assign(CodemanApp.prototype, {
// CJK input has focus — block xterm from sending to PTY
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
if (this.activeSessionId) {
// Filter out terminal query responses that xterm.js generates automatically.
// These are responses to DA (Device Attributes), DSR (Device Status Report), etc.
// sent by tmux when attaching. Without this filter, they appear as typed text.
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
// Filter terminal query replies generated by xterm.js itself.
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
// into the foreground process as typed input (for example "0;276;0c").
if (
window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data)
) {
return;
}
this._lastTerminalData = { data, time: performance.now() };
// ── Local Echo Mode ──
// When enabled, keystrokes are buffered locally in the overlay for
@@ -1594,6 +1636,38 @@ Object.assign(CodemanApp.prototype, {
}
},
scrollToLastNonEmptyLine() {
if (!this.terminal?.buffer?.active) {
this.terminal?.scrollToBottom?.();
return;
}
const buffer = this.terminal.buffer.active;
const totalLines = buffer.baseY + buffer.length;
let lastNonEmptyLine = -1;
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
const line = buffer.getLine(lineIndex);
if (line?.translateToString(true).trim()) {
lastNonEmptyLine = lineIndex;
break;
}
}
if (lastNonEmptyLine >= 0 && typeof this.terminal.scrollToLine === 'function') {
let targetLine = Math.max(0, lastNonEmptyLine - this.terminal.rows + 2);
const maxTargetLine = Math.max(0, lastNonEmptyLine);
while (targetLine < maxTargetLine) {
const line = buffer.getLine(targetLine);
if (line?.translateToString(true).trim()) break;
targetLine++;
}
this.terminal.scrollToLine(targetLine);
} else {
this.terminal.scrollToBottom();
}
},
/**
* Write large buffer to terminal in chunks to avoid UI jank.
* Uses _safeYield to spread work across frames; falls back to setTimeout
@@ -1602,22 +1676,19 @@ Object.assign(CodemanApp.prototype, {
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
// older writes abort instead of continuing to push stale data into the terminal.
const writeGen = ++this._chunkedWriteGen;
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
this._finishBufferLoad();
this._finishBufferLoad(bufferLoadOwner);
resolve();
return;
}
// Block live SSE writes during buffer load to prevent interleaving
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
// Strip any DEC 2026 markers that might be in the buffer
// (from historical SSE data that was stored with markers)
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
@@ -1625,15 +1696,14 @@ Object.assign(CodemanApp.prototype, {
const finish = () => {
// Only finish if we're still the active write — a newer write owns buffer load state
if (this._chunkedWriteGen === writeGen) {
this._finishBufferLoad();
this._finishBufferLoad(bufferLoadOwner);
}
resolve();
};
// For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer);
finish();
this.terminal.write(cleanBuffer, finish);
return;
}
@@ -1691,9 +1761,23 @@ Object.assign(CodemanApp.prototype, {
* (especially Ink cursor-up redraws), corrupting the terminal display.
* After unblocking, new SSE/WS events deliver subsequent output normally.
*/
_finishBufferLoad() {
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
this._bufferLoadOwner = loadOwner;
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
return loadOwner;
},
_finishBufferLoad(owner) {
if (owner !== undefined && this._bufferLoadOwner !== owner) {
return false;
}
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
return true;
},
// ═══════════════════════════════════════════════════════════════
@@ -1814,23 +1898,30 @@ Object.assign(CodemanApp.prototype, {
/**
* Send resize to a session with minimum dimension enforcement.
* @param {string} sessionId
* @param {{ forceHttp?: boolean }} [options]
* @returns {Promise<void>}
*/
async sendResize(sessionId) {
async sendResize(sessionId, options = {}) {
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
const dims = this.getTerminalDimensions();
if (!dims) return;
if (!dims) return false;
// Did the dimensions actually change since the last resize we sent? Callers
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
// real SIGWINCH was triggered — switching tabs at the same browser size is
// a no-op on the server and needs no redraw grace.
const prev = this._lastResizeDims;
const changed = !prev || prev.cols !== dims.cols || prev.rows !== dims.rows;
// Update _lastResizeDims so the throttledResize handler won't redundantly
// clear the terminal for the same dimensions (which would blank the screen
// without a subsequent Ink redraw to repaint it).
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
// Fast path: WebSocket resize
if (this._wsReady && this._wsSessionId === sessionId) {
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
try {
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
return;
return changed;
} catch {
// Fall through to HTTP POST
}
@@ -1840,6 +1931,7 @@ Object.assign(CodemanApp.prototype, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims),
});
return changed;
},
/**
+3 -2
View File
@@ -888,8 +888,9 @@ export function registerSessionRoutes(
const query = req.query as { tail?: string };
const session = findSessionOrFail(ctx, id);
const rawBuffer = session.terminalBuffer;
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const fullSize = session.terminalBufferLength;
const fullSize = rawBuffer.length;
let truncated = false;
let cleanBuffer: string;
@@ -897,7 +898,7 @@ export function registerSessionRoutes(
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
const strippedBuffer = stripInkRedrawBloat(session.terminalBuffer);
const strippedBuffer = stripInkRedrawBloat(rawBuffer);
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
+19
View File
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function cssRuleBody(css: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
return match?.[1] ?? '';
}
describe('terminal layout CSS', () => {
it('pins the xterm root to the terminal container width while scrollback moves', () => {
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const xtermRule = cssRuleBody(css, '.terminal-container .xterm');
expect(xtermRule).toContain('width: 100%');
expect(xtermRule).toContain('min-width: 0');
});
});
+15
View File
@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('tmux styled pane capture', () => {
it('requests SGR style escapes for canonical browser replay frames', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const captureMethod = source.indexOf('capturePaneBuffer(muxName: string');
const captureCommand = source.indexOf('capture-pane -p', captureMethod);
expect(captureMethod).toBeGreaterThan(-1);
expect(captureCommand).toBeGreaterThan(captureMethod);
expect(source.slice(captureCommand, captureCommand + 80)).toContain('capture-pane -p -e');
});
});
+106 -2
View File
@@ -8,8 +8,8 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TmuxManager, parsePaneList } from '../src/tmux-manager.js';
import { execSync } from 'node:child_process';
import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
import { execSync, exec } from 'node:child_process';
// ============================================================================
// Unit Tests (mocked)
@@ -64,6 +64,7 @@ vi.mock('node:fs/promises', async () => {
describe('TmuxManager (unit)', () => {
let manager: TmuxManager;
const mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
beforeEach(() => {
vi.clearAllMocks();
@@ -118,6 +119,109 @@ describe('TmuxManager (unit)', () => {
});
});
describe('window sizing', () => {
it('pins a tmux window to manual sizing before browser attach', () => {
expect(manager.setManualWindowSize('codeman-abc12345')).toBe(true);
expect(mockedExecSync).toHaveBeenCalledWith(
"tmux -L 'codeman' set-window-option -t 'codeman-abc12345' window-size manual",
expect.objectContaining({ stdio: 'ignore' })
);
});
it('resizes the tmux window when Codeman accepts a desktop resize', () => {
expect(manager.resizeWindow('codeman-abc12345', 140, 42)).toBe(true);
// Non-blocking exec (not execSync) on the interactive resize hot path.
expect(mockedExec).toHaveBeenCalledWith(
"tmux -L 'codeman' resize-window -t 'codeman-abc12345' -x 140 -y 42",
expect.objectContaining({ timeout: expect.any(Number) }),
expect.any(Function)
);
});
});
describe('environment exports', () => {
it('keeps COLORTERM unset for OpenCode sessions', () => {
const exports = (
manager as unknown as {
buildEnvExports(sessionId: string, muxName: string, mode: string): string[];
}
).buildEnvExports('session-1', 'codeman-abc12345', 'opencode');
expect(exports).toContain('unset COLORTERM');
});
});
describe('formatPaneSnapshot', () => {
it('paints captured rows with absolute cursor positions to avoid newline autowrap scroll', () => {
const fullWidthLine = 'x'.repeat(10);
const snapshot = formatPaneSnapshot([fullWidthLine, 'next line'], {
cols: 10,
rows: 4,
cursorX: 2,
cursorY: 1,
});
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(9)}\x1b[2;1Hnext line\x1b[2;3H`);
expect(snapshot).not.toContain('\n');
});
it('preserves SGR color while stripping non-style pane controls', () => {
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
cols: 40,
rows: 2,
cursorX: 0,
cursorY: 0,
});
expect(snapshot).toContain('\x1b[32mgreen\x1b[0m');
expect(snapshot).toContain('tail');
expect(snapshot).not.toContain('\x1b[2K');
expect(snapshot).not.toContain('\x1b[10;20H');
});
it('truncates styled rows by visible columns without cutting SGR escapes', () => {
const snapshot = formatPaneSnapshot(['\x1b[31mabcdef\x1b[0m'], {
cols: 4,
rows: 1,
cursorX: 0,
cursorY: 0,
});
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabc\x1b[0m\x1b[1;1H');
});
it('does not let full-width glyphs cross the paint boundary', () => {
const snapshot = formatPaneSnapshot(['abc\u754cdef'], {
cols: 5,
rows: 1,
cursorX: 0,
cursorY: 0,
});
expect(snapshot).toBe('\x1b[1;1Habc\x1b[1;1H');
});
it('keeps combining marks attached without consuming a terminal column', () => {
const snapshot = formatPaneSnapshot(['a\u0301bc'], {
cols: 4,
rows: 1,
cursorX: 0,
cursorY: 0,
});
expect(snapshot).toBe('\x1b[1;1Ha\u0301bc\x1b[1;1H');
});
});
describe('resolveActivePaneTarget', () => {
it('selects the active pane instead of assuming pane zero', () => {
expect(resolveActivePaneTarget('%1:0\n%18:1\n')).toBe('%18');
});
});
describe('isAvailable', () => {
it('should return true when tmux is found', () => {
mockedExecSync.mockImplementation((cmd: string) => {