diff --git a/README.md b/README.md
index c929fa12..76e04f36 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
The missing control plane for AI coding agents
- Agent Visualization • Zero-Lag Input • Autonomous Orchestrator • Respawn Controller • Mobile-First UI • Hardened Security
+ Agent Visualization • Zero-Lag Input • Mobile-First UI • Hardened Security
diff --git a/src/mux-interface.ts b/src/mux-interface.ts
index 7ada57c2..724ee729 100644
--- a/src/mux-interface.ts
+++ b/src/mux-interface.ts
@@ -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;
+
+ /** 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;
}
diff --git a/src/session.ts b/src/session.ts
index f8d049bf..cfc907d8 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -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);
}
}
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index 48d2cdde..2fb70169 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -164,6 +164,280 @@ export function parsePaneList(output: string): Map {
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 `),
+ // 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();
}
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 19e21371..821cbf2b 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -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
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(`
+ parts.push(`
${_tabIdx < 9 ? '' + (_tabIdx + 1) + '' : ''}
+ ${loadState ? '' : ''}
@@ -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 = {};
diff --git a/src/web/public/constants.js b/src/web/public/constants.js
index 9e5b7723..3c7b538b 100644
--- a/src/web/public/constants.js
+++ b/src/web/public/constants.js
@@ -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;
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index d4200a39..0bfcd24e 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -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;
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 912788f5..6c292b47 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -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;
}
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 7aae408e..c160fe75 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -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} - 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}
*/
- 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;
},
/**
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index 362b50a5..23a0b14e 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -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.
diff --git a/test/terminal-layout-css.test.ts b/test/terminal-layout-css.test.ts
new file mode 100644
index 00000000..aba9c23e
--- /dev/null
+++ b/test/terminal-layout-css.test.ts
@@ -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');
+ });
+});
diff --git a/test/tmux-capture-color.test.ts b/test/tmux-capture-color.test.ts
new file mode 100644
index 00000000..6db4d9f0
--- /dev/null
+++ b/test/tmux-capture-color.test.ts
@@ -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');
+ });
+});
diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts
index d1559b96..cd1ad7a0 100644
--- a/test/tmux-manager.test.ts
+++ b/test/tmux-manager.test.ts
@@ -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) => {