feat(terminal): tmux pane-buffer primitives and session/render reliability - #112
Conversation
Ark0N
left a comment
There was a problem hiding this comment.
Thanks for extracting this foundation work, @aakhter. I did a deep review (multi-agent + ran the unit suite and loaded the result locally) and unfortunately it isn't landable yet — there are several confirmed blockers, and importantly all of them currently pass CI green, because CI lints only src/**/*.ts (not frontend .js), tsc excludes test/, and the unit suite isn't run in CI. Please npm test -- test/tmux-manager.test.ts and load the page in a browser before re-requesting review. (We're tightening CI to cover these.)
This looks like a partial extraction from a larger "codex CLI mode" branch — several pieces depend on a 'codex'SessionMode and a buildCodexCommand that don't exist on master, which is the root of blocker #3.
Blockers
Fatal
SyntaxErrorinsession-ui.js— breaks the whole module.finishRenameaddsconst suffix/let fullNamebut leaves the originalconst suffix/const fullNamein the same block →Identifier 'suffix' has already been declared(verified withnode --check). Sincesession-ui.jsis a plain<script>(no bundler), the parse error kills the entire file — inline rename, case settings, detach, and every method it attaches toCodemanApp.prototypebecome undefined. → delete the leftover duplicate declarations.createSessiondrops the dedicated-Lsocket and reverts the cwd hardening (tmux-manager.ts). Changed from${this.tmux()} new-session … -c ${TMUX_LAUNCH_CWD}to baretmux new-session … -c "${workingDir}". The session is created on the default tmux server while every other call uses-L <socket>→ spawn breaks; it also re-introduces the FUSE/rclonegetcwdissue #110 fixed. → restore${this.tmux()}+-c ${TMUX_LAUNCH_CWD}(andcwd: TMUX_LAUNCH_CWD). The same socket-drop also appears insession.ts's re-attach window-size query (see #4).The snapshot/replay feature is dead code on master, and its tests won't load. The client path is gated on
session.mode === 'codex', butSessionMode = 'claude' | 'shell' | 'opencode'— there is no'codex'.test/tmux-manager.test.tsimportsbuildCodexCommand(exists nowhere) so the file throws at module load;test/routes/session-routes.test.tssetsmode = 'codex'; abuildEnvExports(…, 'codex')test assertsCOLORTERM=truecolorthe shipped code doesn't emit. → either include thecodex-mode dependency this branch needs, or strip the codex-gating and re-gate the snapshot feature on an existing mode; remove/port the codex-only tests accordingly.session.tsre-attach window-size query drops the-Lsocket, so it queries the default server, always throws, and falls back to 120×40 — defeating the anti-flicker sizing the surrounding comment promises. → keepqueryTmuxWindowSize(muxName, mux.muxSocket).selectSessiondrops detached-window handling — popped-out tabs now load inline instead of raising their existing window. → restore the_raiseDetachedshort-circuit.Synchronous pane capture on the
/api/sessions/:id/terminalrequest path for ALL modes.captureActivePaneBufferruns 3 blockingexecSynccalls (list-panes/capture-pane/display-message, 5s timeout each) on every terminal fetch / tab switch, un-gated by mode → can stall the Fastify event loop up to ~15s and append a repainted frame onto the (up to 2MB) accumulated buffer. → gate to the relevant mode and move it off the synchronous request path.
Should-fix (medium)
formatPaneSnapshotpaints tocols - 1, dropping the rightmost column of every captured row.- The localStorage snapshot cache isn't LRU; with >10 live sessions it evicts nothing → shared-quota exhaustion.
- Hardcoded 400ms delay added to every non-shell tab switch.
- Vendored
xterm-addon-serialize.min.js: it's genuine-by-content but has nopackage.jsondependency and no regen path. → add@xterm/addon-serializeas a pinned dep (or document the vendoring + a fetch script) so it's reproducible and tracks the xterm v6 internals it depends on.
The sound parts — SGR/grapheme handling in formatPaneSnapshot, the buffer-load owner-token race fix, active-pane resolution, OSC/CSI suppression — are good. Happy to re-review promptly once the blockers are addressed.
…ility Mode-agnostic terminal foundation extracted from the downstream branch: - formatPaneSnapshot: SGR/grapheme-aware tmux pane capture + active-pane resolution, with OSC/CSI redraw suppression and the buffer-load owner-token race fix on the terminal fetch path - socket-correct tmux lifecycle: dedicated -L socket and /tmp launch cwd in createSession (restores the FUSE/getcwd hardening from Ark0N#110), and a socket-aware re-attach window-size query (avoids the 120x40 flicker) - inline-rename: commit/cancel state handling clears _activeRename and skips the API call on cancel - selectSession: restored detached-window raise short-circuit The codex-specific xterm snapshot/replay, the vendored serialize addon, and the synchronous live pane-capture on the request path are intentionally excluded: they depend on a 'codex' SessionMode that doesn't exist on master and are deferred to COD-34 (which introduces that mode). The capture primitives remain exported for COD-34 to build on. Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
aakhter
commented
Jun 9, 2026
Reworked and force-pushed (now Scope decision on the snapshot/replay (blocker #3)You offered two options — pull in the codex-mode dependency, or re-gate the snapshot feature on an existing mode. I took a third path: removed the codex-specific snapshot/replay from this PR and deferred it to the codex run-mode PR (where the Reasoning — re-gating on an existing mode would regress it. The client xterm snapshot/replay only helps codex (its TUI redraws drop earlier scrollback, so server byte-replay shows just the latest frame). For claude/shell/opencode the server replay is already correct, so restoring a serialized snapshot on tab-switch would show stale content — and those modes already get instant paint from the existing Blockers
Should-fixesThe non-LRU snapshot cache and the un-pinned vendored serialize addon are now moot (feature removed). The Verification
|
Follow-up to the PR Ark0N#112 re-review (all six prior blockers were already resolved; these are new issues the rework introduced): - app.js: define the missing `_scheduleTerminalRepaint()` helper. It was called from both WebGL-fallback paths (onContextLoss + long-task trip) but defined nowhere, so each fallback threw `TypeError` and lost the post-fallback repaint, leaving a stale/blank terminal. Implemented as an rAF-debounced full refresh (matches the old inline `terminal.refresh`). - app.js: clear terminal load-state on the two post-write stale-select early-returns (cached-buffer + rewrite branches), matching the other four checks. Switching away from a mid-loading tab no longer leaks a permanent `.tab-loading` spinner / `aria-busy=true`. - terminal-ui.js + app.js: gate the post-resize TUI-redraw settle on an actual dimension change. `sendResize` now returns whether dims changed; a same-size tab switch sends no SIGWINCH, so the wait is skipped instead of charging a flat tax on every non-shell switch. Literal hoisted to `TUI_REDRAW_SETTLE_MS`. - tmux-manager.ts: `resizeWindow()` uses a non-blocking `exec` instead of `execSync` so the interactive WS/HTTP resize path can't stall the Fastify event loop on a slow/hung tmux. Sole caller already fire-and- forgets the result; test updated to assert the async dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for the thorough rework, @aakhter — I re-reviewed 0569f68 and independently re-ran the unit suites, tsc, eslint, and node --check.
✅ All six blockers confirmed fixed
- session-ui.js SyntaxError + rename semantics — duplicate decls gone, cancel no longer fires the PUT,
_activeRename/_inlineRenameActivecleared on both paths.node --checkclean. - createSession socket/cwd — back through
${this.tmux()}+-c ${TMUX_LAUNCH_CWD};tmux-manager.test.tslocks the exacttmux -L 'codeman' new-session … -c /tmpstring. - snapshot/replay dead code — removed cleanly, no orphaned codex/serialize wiring; deferring it to a follow-up makes sense.
- session.ts window size — socket-aware
queryTmuxWindowSize(muxName, mux.muxSocket). ✔ - selectSession —
_raiseDetachedshort-circuit restored as the first statement. ✔ - sync pane-capture on
GET …/terminal— reverted tosession.terminalBuffer;captureActivePaneBufferhas zero call sites. ✔
tsc --noEmit 0, eslint 0, tmux-manager.test.ts 42/42, tmux-capture-color + terminal-layout-css + session-routes 45/45.
🔴 Two new bugs the rework introduced
Both are off the happy path, which is likely why the Playwright load didn't surface them.
1. this._scheduleTerminalRepaint() is called but never defined → TypeError
app.js:655 (WebGL onContextLoss) and app.js:686 (long-task fallback trip) both call this._scheduleTerminalRepaint(), but it's defined nowhere (grep -rn _scheduleTerminalRepaint src/ → only those 2 call sites; it didn't exist pre-PR). The refactor replaced the old inline try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {} with a helper that was never added. When WebGL context is lost / the long-task guard trips, the addon is disposed and then this throws — so the post-fallback repaint is lost and the terminal goes stale/blank on the canvas/DOM renderer. node --check passes (it's a runtime TypeError, not a parse error), which is why CI stayed green.
Fix — add the helper next to _disposeWebGLObserver:
/** * 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. */_scheduleTerminalRepaint(){if(this._terminalRepaintScheduled)return;this._terminalRepaintScheduled=true;constraf=typeofrequestAnimationFrame==='function' ? requestAnimationFrame : (cb)=>setTimeout(cb,0);raf(()=>{this._terminalRepaintScheduled=false;try{this.terminal?.refresh(0,this.terminal.rows-1);}catch{}});}(or just revert both call sites to the old inline refresh.)
2. Loading-spinner leaks permanently on switch-away-mid-load
selectSession has six _isStaleSelect(selectGen) early-returns. Four clear the load state first; the two that fire after a chunkedTerminalWrite don't:
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
- if (this._isStaleSelect(selectGen)) return;+ if (this._isStaleSelect(selectGen)) {+ this._clearTerminalLoadState(sessionId, selectGen);+ return;+ }
this.terminal.scrollToBottom(); await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
- if (this._isStaleSelect(selectGen)) return;+ if (this._isStaleSelect(selectGen)) {+ this._clearTerminalLoadState(sessionId, selectGen);+ return;+ }
// Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom();Switching A→B while A is mid-load resumes A's select, sees it's stale, and returns without clearing — so tab A keeps .tab-loading + the spinner + aria-busy="true" until A is reselected/deleted. _clearTerminalLoadState already no-ops when a newer generation owns the entry, so this is safe.
Two "your call" items
3. The 400ms non-shell tab-switch delay — better to fix it here than defer. It's gated only by mode, so it fires even when dims didn't change (the common same-size tab switch sends no SIGWINCH → no redraw to wait for), taxing the core workflow. Gate it on a real resize: have sendResize report whether dims changed and only wait then —
// terminal-ui.js sendResize(): after computing `dims`constprev=this._lastResizeDims;constchanged=!prev||prev.cols!==dims.cols||prev.rows!==dims.rows;// … set this._lastResizeDims, then `return changed;` on both the WS and HTTP paths// (and `return false` on the early `if (!dims)`).// app.js selectSession()constdimsChanged=awaitthis.sendResize(sessionId,{forceHttp: true}).catch(()=>false);…if(session?.mode!=='shell'&&dimsChanged){awaitnewPromise((resolve)=>setTimeout(resolve,TUI_REDRAW_SETTLE_MS));// hoist 400 → named const…}4. formatPaneSnapshotcols-1 — agreed, fold into the codex PR. It's in the capture primitives that have zero production callers on master, and the right fix (paintCols = cols) needs the wide-char/grapheme test expectations re-pinned, which the codex PR's end-to-end capture coverage should own.
One more (low, optional)
resizeWindow() uses execSync on the resize hot path (tmux-manager.ts). It's reachable from the live WS {t:'z'} handler + HTTP /resize; a slow/hung tmux blocks the Fastify event loop. Debounced ~1/gesture so low-severity, but trivial to make non-blocking since Session.resize already fire-and-forgets the result:
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);});returntrue;Net: 1 and 2 are the hard blockers; 3 is a worthwhile cleanup to land here, and the resizeWindow change is optional.
Ark0N
left a comment
There was a problem hiding this comment.
Pushed the re-review fixes directly to the branch as b75181b (maintainer edits enabled): items 1, 2, 3, and the optional resizeWindow change. #4 stays folded into the codex PR as discussed.
Re-verified on the merge result — tsc --noEmit 0, eslint 0, node --check on the touched modules clean, tmux-manager.test.ts 42/42, and CI is green on b75181b. Approving. Thanks for the solid rework, @aakhter.
Uh oh!
There was an error while loading. Please reload this page.
Review follow-ups on PR Ark0N#111 (rebased onto master post-Ark0N#112/Ark0N#113): Resize arbitration redesigned (review blocker 2): the previous 'cols < _ptyCols' guard froze a mobile-only session's PTY at the spawn default — narrow phones rendered clipped and could never re-fit. The guard now uses connection-scoped desktop sizing claims instead: ws-routes registers a claim on a desktop-typed resize and releases it on socket close (or when the same connection later reports a small viewport), and Session.resize() ignores mobile/tablet resizes only while at least one desktop connection holds a claim. A phone alone fully controls its size (shrink, rows-only shrink, re-grow); a phone glancing at a desktop-driven session can no longer reflow it. mobile-handlers' keyboard open/close resize now declares its viewport type so it participates in arbitration. Tests rewritten to cover mobile-only shrink/rows-only/re-grow, claim/release lifecycle, multi- claim behavior, and untyped legacy resizes; ws-routes test covers the claim lifecycle over a real socket. Solo/detached header restored (review blocker 3): index.html had removed #soloSessionTitle and #soloRedockBtn, which _applySoloMode still references — every detached window hit a null deref. Both are back alongside the new mobile utility toggle. Desktop leak fixed (review should-fix): .mobile-header-utility-toggle had no rule outside the <=768px media queries, so the raw button rendered on desktop. styles.css now hides it by default; the mobile/ tablet queries re-enable it. Visual-regression baselines reverted to master (review should-fix): the 18 contributor-machine PNGs are environment-specific (8 of the behavioral tests already report environment-sensitive failures across machines); re-baseline deliberately on the canonical machine instead. The 24 behavioral keyboard/layout/tabs tests are kept as-is. AGENTS.md trimmed to a pointer at CLAUDE.md (review should-fix) to avoid drift between duplicated guidance. Also dropped a dead getAttachmentHistoryForPersist stub (codex-branch residue — no such method exists in src/). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Terminal rendering and buffer-replay foundation for the web UI, extracted as a focused change set (Codex/Gemini-specific redraw handling intentionally left out):
Verification
tsc --noEmitpasseseslintpassesNotes
This is the shared terminal foundation only; Codex/Gemini run-mode behavior is out of scope and excluded. Extracted from downstream work; includes the related tmux/terminal unit tests.