mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
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:
+177
-32
@@ -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 = {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user