diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 7ada57c2b..724ee729f 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 f8d049bf6..cfc907d8d 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 e30c25922..cc44b855e 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 { @@ -1678,8 +1959,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 ''; @@ -1695,16 +1979,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. @@ -1774,6 +2109,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 03b2d218a..4bd03afcd 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(`