Skip to content

feat(terminal): tmux pane-buffer primitives and session/render reliability - #112

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
aakhter:pr/cod-32-terminal
Jun 10, 2026
Merged

feat(terminal): tmux pane-buffer primitives and session/render reliability#112
Ark0N merged 2 commits into
Ark0N:masterfrom
aakhter:pr/cod-32-terminal

Conversation

@aakhter

Copy link
Copy Markdown
Contributor

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):

  • xterm snapshot/serialize: snapshot + restore xterm state per session on tab switch; persist snapshots to localStorage so terminal content survives tab discard.
  • Live tmux pane capture: capture the active pane (active-pane resolution) and combine saved history with the live pane; sync the terminal buffer from the pane.
  • Buffer replay: replay tmux snapshots as plain text; position snapshot rows; replay busy shell buffers on tab switch; keep replayed content visible; refresh/reload and repaint stability (fallback anchors, buffer-load ownership).
  • Sizing & rendering: keep terminal width stable during scrollback; pin tmux terminal sizing; color/cell-width handling; suppress spurious OSC terminal replies.

Verification

  • tsc --noEmit passes
  • eslint passes

Notes

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.

@Ark0NArk0N left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Fatal SyntaxError in session-ui.js — breaks the whole module.finishRename adds const suffix / let fullName but leaves the original const suffix / const fullName in the same block → Identifier 'suffix' has already been declared (verified with node --check). Since session-ui.js is a plain <script> (no bundler), the parse error kills the entire file — inline rename, case settings, detach, and every method it attaches to CodemanApp.prototype become undefined. → delete the leftover duplicate declarations.

  2. createSession drops the dedicated -L socket and reverts the cwd hardening (tmux-manager.ts). Changed from ${this.tmux()} new-session … -c ${TMUX_LAUNCH_CWD} to bare tmux 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/rclone getcwd issue #110 fixed. → restore ${this.tmux()} + -c ${TMUX_LAUNCH_CWD} (and cwd: TMUX_LAUNCH_CWD). The same socket-drop also appears in session.ts's re-attach window-size query (see #4).

  3. The snapshot/replay feature is dead code on master, and its tests won't load. The client path is gated on session.mode === 'codex', but SessionMode = 'claude' | 'shell' | 'opencode' — there is no 'codex'. test/tmux-manager.test.ts imports buildCodexCommand (exists nowhere) so the file throws at module load; test/routes/session-routes.test.ts sets mode = 'codex'; a buildEnvExports(…, 'codex') test asserts COLORTERM=truecolor the shipped code doesn't emit. → either include the codex-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.

  4. session.ts re-attach window-size query drops the -L socket, so it queries the default server, always throws, and falls back to 120×40 — defeating the anti-flicker sizing the surrounding comment promises. → keep queryTmuxWindowSize(muxName, mux.muxSocket).

  5. selectSession drops detached-window handling — popped-out tabs now load inline instead of raising their existing window. → restore the _raiseDetached short-circuit.

  6. Synchronous pane capture on the /api/sessions/:id/terminal request path for ALL modes.captureActivePaneBuffer runs 3 blocking execSync calls (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)

  • formatPaneSnapshot paints to cols - 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 no package.json dependency and no regen path. → add @xterm/addon-serialize as 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
aakhterforce-pushed the pr/cod-32-terminal branch from da0672a to 0569f68CompareJune 9, 2026 19:39
@aakhter

Copy link
Copy Markdown
ContributorAuthor

Reworked and force-pushed (now 0569f68, single clean commit). All six blockers addressed; verified locally with the unit suite and a browser load (Playwright), since CI doesn't cover frontend .js, test/, or the unit suite.

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 'codex'SessionMode actually lands).

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 terminalBufferCache path. So this PR now ships only the mode-agnostic primitives you flagged as sound; the snapshot/replay, the vendored serialize addon, and the live pane-capture return with the codex mode.

Blockers

  1. session-ui.js SyntaxError — removed the duplicate suffix/fullName declarations. The rename tests then revealed the same botched merge had also dropped master's finishRename({ commit }) semantics (cancel still fired the PUT; _activeRename never cleared), so I restored those too, keeping this branch's _inlineRenameActive re-render guard. node --check clean; test/inline-rename.test.ts 7/7.
  2. createSession socket/cwd — restored ${this.tmux()} dedicated -L socket + -c ${TMUX_LAUNCH_CWD} (and cwd), reinstating the fix: harden tmux launch cwd #110 FUSE/getcwd hardening.
  3. snapshot/replay dead code — removed + deferred (above); dropped the codex-only tests (buildCodexCommand, mode='codex', and the COLORTERM=truecolor assertion the shipped code doesn't emit).
  4. session.ts re-attach window size — now uses the socket-aware queryTmuxWindowSize(muxName, mux.muxSocket) instead of bare tmux display, so it no longer always falls back to 120×40.
  5. selectSession — restored the _raiseDetached short-circuit so popped-out tabs raise their window instead of loading inline.
  6. synchronous pane capture — removed captureActivePaneBuffer from the GET /api/sessions/:id/terminal request path (reverts to session.terminalBuffer); no more blocking execSync on tab switch. The capture primitives stay exported + tested for the codex PR to re-add async and bounded.

Should-fixes

The non-LRU snapshot cache and the un-pinned vendored serialize addon are now moot (feature removed). The formatPaneSnapshot rightmost-column (cols-1) and the hardcoded 400 ms non-shell tab-switch delay are still present in the retained primitive / selectSession — happy to fix here or fold into the codex PR, your call.

Verification

test/tmux-manager.test.ts 42 ✓ (was throwing at module load), test/inline-rename.test.ts 7/7 ✓, session-routes + tmux-window-size-query 58 ✓, tsc --noEmit + eslint clean. Browser load via the Playwright mobile/inline-rename suites — fixing the parse error repaired 9 previously-failing tab keyboard/swipe-navigation tests. A full-suite diff vs the prior commit shows net fewer failures and no regressions attributable to this rework (the remaining failures are pre-existing mobile-CSS/visual/perf tests unrelated to these files). Ready for re-review.

@aakhteraakhter changed the title feat(terminal): xterm snapshot/replay and tmux buffer rendering foundationfeat(terminal): tmux pane-buffer primitives and session/render reliabilityJun 9, 2026
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>

@Ark0NArk0N left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. session-ui.js SyntaxError + rename semantics — duplicate decls gone, cancel no longer fires the PUT, _activeRename/_inlineRenameActive cleared on both paths. node --check clean.
  2. createSession socket/cwd — back through ${this.tmux()} + -c ${TMUX_LAUNCH_CWD}; tmux-manager.test.ts locks the exact tmux -L 'codeman' new-session … -c /tmp string.
  3. snapshot/replay dead code — removed cleanly, no orphaned codex/serialize wiring; deferring it to a follow-up makes sense.
  4. session.ts window size — socket-aware queryTmuxWindowSize(muxName, mux.muxSocket). ✔
  5. selectSession_raiseDetached short-circuit restored as the first statement. ✔
  6. sync pane-capture on GET …/terminal — reverted to session.terminalBuffer; captureActivePaneBuffer has 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.

@Ark0NArk0N left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Ark0N
Ark0N merged commit 227495a into Ark0N:masterJun 10, 2026
1 check passed
Ark0N added a commit that referenced this pull request Jun 10, 2026
…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>
Ark0N added a commit to aakhter/Codeman that referenced this pull request Jun 10, 2026
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@aakhter@Ark0N