# CTM Session Render State Management Design

Status: discussion draft
Date: 2026-05-06
Scope: CTM browser terminal restore, restart readiness, tab switching, split panes,
hidden output, resize/reflow, reconnect, and diagnostics.

## Problem Statement

CTM currently has many local guards for terminal restore:

- server restore broadcasts `server-ready`;
- tab activation decides whether to reuse a hot terminal, restore saved text,
  restore a cached snapshot, attach, request a snapshot, or reflow;
- snapshots can be cached, interim, stale, dimension mismatched, timed out, or
  skipped to avoid clobbering live input;
- xterm writes are tracked by a render ledger;
- overlays and retry timers are managed separately.

Those pieces are useful, but they do not share a single per-session state
contract. This makes the browser vulnerable to false readiness:

- `server-ready` means CTM recreated session processes, not that the active
  terminal is ready to read.
- `snapshot` arrival means bytes reached the browser, not that xterm has written
  and painted them.
- `activeTab` is not the same as visible. Split panes can make multiple sessions
  visible.
- a hidden session can keep outputting while the active tab is elsewhere.
- a no-op tab switch, resize, or UI panel transition can accidentally request a
  fresh snapshot or reflow.

The result is the class of bugs where a restored session looks half loaded,
blank, stale, or needlessly redraws a few seconds later.

## Design Goal

CTM should track, per session, whether the visible terminal view is fully
rendered for the latest known session state. A tab should refresh only when an
observable input to the view changed:

- terminal output changed;
- terminal size or layout changed;
- renderer state changed or was recreated;
- server attachment/subscription state changed;
- the current local buffer is missing, stale, or known non-authoritative.

If none of those changed, tab activation should be a cheap paint/focus operation:
show the existing terminal, fit if necessary, refresh the canvas, align helper
textarea, preserve scroll intent, and avoid attach/snapshot/reflow.

## TerminalRenderController Contract

The browser must have one mutation owner per terminal session:
`TerminalRenderController`. The server can classify output, coalesce obvious
transport bursts, and produce authoritative headless snapshots, but it cannot
own browser rendering because it does not know the active DOM, xterm parser
state, WebGL renderer state, split visibility, focused input, or scroll intent.

All xterm mutations should route through the controller:

- `term.write()` for live PTY bytes, snapshots, cursor repair, status cleanup,
  and lifecycle banners;
- `term.refresh()` and `clearTextureAtlas()` for paint recovery;
- `fitAddon.fit()` and `term.resize()` for layout changes;
- `scrollToLine()` for follow-bottom and preserved-scroll restoration.

The controller is not just a wrapper. It is the ordering contract:

- all writes enter xterm through the controller, which records pending callbacks
  and relies on xterm's FIFO parser for normal live output; barrier jobs such as
  snapshot restore and renderer repair wait for the relevant write callbacks
  before they declare the view settled;
- synchronized output frames are treated as atomic paint regions, so refresh,
  fit, scroll, and repair jobs wait until the frame closes;
- snapshot restore is a barrier job with a restore epoch/source, and stale
  snapshots are rejected or sanitized before replaying;
- active native input/composer display owns the screen until its lease settles;
- paint/fit/scroll requests are intents queued behind writes instead of direct
  side effects from arbitrary timers;
- render tests wait on controller/readiness state or persistent semantic output,
  not on transient text that a legitimate full-screen redraw can erase.

This makes the existing `SessionViewState` useful as the high-level state model
and moves low-level terminal side effects into a single place.

## Existing Useful Pieces

The current code already has several strong building blocks.

| Area | Current location | Why it matters |
| --- | --- | --- |
| Activation decision helper | `public/js/terminal-restore-state.js` | Encapsulates part of the restore decision, but only as one-shot decisions. |
| Restart orchestration | `public/index.html:onServerReady` | Restores active tab and attaches visible sessions after server restore. |
| Tab activation | `public/index.html:activateTab` | Owns hot terminal reuse, lazy xterm creation, snapshot fallback, retry, blank-check, focus, and scroll guards. |
| Render ledger | `public/index.html:_terminalRenderLedger` and `_terminalRenderCheckFullyRendered` | Tracks expected/rendered chars, pending writes, writer queues, snapshot blockers, placeholders, and authoritative snapshot needs. |
| Snapshot restore | `public/index.html:onSnapshot` | Handles interim/full snapshots, stale snapshot bypass, dimension mismatch, retry, reflow, and active-input clobber guards. |
| Server attach | `server.js:handleAttach` | Sends cached/interim snapshots and schedules fresh headless serialization. |
| Server restart restore | `server.js:_restoreSessionsAsync` | Restores sessions, loads scrollback, seeds headless terminal, and warms snapshot cache. |

The main problem is not that these mechanisms are missing. The problem is that
their state is scattered across booleans and timeouts, so readiness and refresh
decisions are implicit.

## Proposed State Model

Add a client-side `SessionViewState` per CTM session. This can start as
in-memory fields attached to the existing session object, with a small helper in
`public/js/terminal-restore-state.js`. It does not need a database migration.

```js
{
  id,
  viewEpoch: 0,
  contentEpoch: 0,
  layoutEpoch: 0,
  rendererEpoch: 0,
  attachEpoch: 0,
  restoreEpoch: 0,
  visibility: 'hidden', // hidden | active | split | panel
  phase: 'idle',        // see state table below
  blockers: [],
  lastReadyAt: 0,
  lastPaintSettledAt: 0,
  lastSnapshot: {
    epoch: 0,
    kind: 'none',       // none | interim | full | reflow | timeout
    cols: 0,
    rows: 0,
    scrollbackRows: null,
    outputMarker: 0,
    receivedAt: 0
  },
  requests: {
    attach: null,
    snapshot: null,
    reflow: null
  }
}
```

Epochs are monotonic counters. They make decisions explicit:

- `contentEpoch` increments when PTY output is accepted, snapshot content is
  applied, scrollback fallback is applied, or terminal content is cleared.
- `layoutEpoch` increments when terminal cols/rows, split layout, panel width,
  font metrics, or visible container dimensions change.
- `rendererEpoch` increments when xterm is created, disposed, reset, WebGL is
  recreated, or canvas context loss is detected.
- `attachEpoch` increments when WS attach/subscription state changes.
- `restoreEpoch` increments for each attach/snapshot/reflow request that expects
  a response.

The coordinator should keep the last fully rendered tuple:

```js
lastReadyTuple = {
  contentEpoch,
  layoutEpoch,
  rendererEpoch,
  attachEpoch,
  restoreEpoch
}
```

If the current tuple equals `lastReadyTuple`, a tab switch must not replay a
snapshot. It may still call xterm `refresh()` and align/focus because those are
idempotent paint operations.

The render controller keeps a lower-level tuple for terminal mutation ordering:

```js
{
  writeSeq,
  activeJob,
  queuedJobs,
  openSynchronizedFrameDepth,
  lastWriteSource,
  lastMutationSource,
  lastSettledAt
}
```

`SessionViewState` answers "is this visible view ready?" while
`TerminalRenderController` answers "which terminal mutation is allowed to run
next?" Both are required. A render ledger without mutation ownership can still
observe a race after it already happened.

## View Phases

| Phase | Meaning | Exit condition |
| --- | --- | --- |
| `idle` | Session is hidden or no terminal view exists. | Activation, split visibility, reconnect, or output. |
| `hot-visible` | Existing terminal is visible and no authoritative restore is needed. | Render ledger settles for current epochs. |
| `attach-requested` | Browser asked server to attach visible session. | Interim/full snapshot, output, timeout, or error. |
| `interim-painted` | Raw scrollback or mismatched cache is visible. | Full snapshot/reflow settles, or degraded state. |
| `snapshot-requested` | Browser expects a full snapshot for current restore epoch. | Full snapshot, timeout, or stale response skip. |
| `snapshot-writing` | Snapshot bytes are being written into xterm. | Tracked xterm write callback plus double requestAnimationFrame. |
| `paint-settling` | Bytes are written and the browser canvas/scroll/focus is settling. | Render ledger fully rendered and paint settled. |
| `ready` | Visible terminal is fully rendered for the latest epochs. | Any content/layout/renderer/attach change. |
| `degraded` | Recovery timed out; CTM shows best available content and exposes retry. | New successful attach/snapshot/reflow. |

## Fully Rendered Predicate

The existing render ledger is close to the right predicate. The coordinator
should use it as the authoritative "view ready" gate.

A visible session is fully rendered only when all of these are true:

- session has a terminal and the terminal container is visible;
- no xterm write jobs are pending;
- `expectedChars` and `renderedChars` agree after canceled jobs are accounted for;
- checksum sums agree for rendered data;
- writer queue is empty;
- no writer batch is scheduled;
- no chunked write is in progress;
- no snapshot request is pending;
- no interim snapshot is still waiting for a full snapshot;
- no restore placeholder is active;
- no `_needsAuthoritativeSnapshot` or `_serverDroppedOutput` flag remains;
- the current restore request epoch is not stale;
- a double `requestAnimationFrame` paint settle ran after the last rendered
  write;
- viewport is at the follow target unless the user intentionally scrolled away.

Restart overlays and tab loading overlays should dismiss from this predicate,
not from snapshot arrival or fixed timers.

## Activation Decision Table

Tab activation should be reduced to a small decision table.

| Current condition | Correct action |
| --- | --- |
| Hot terminal exists, epochs unchanged, no blockers | Show, fit if needed, refresh canvas, focus if safe. No attach, snapshot, or reflow. |
| Hot terminal exists, hidden output queued, session stayed subscribed | Flush local queue and settle. No snapshot unless queue was truncated or authoritative snapshot needed. |
| Terminal exists but renderer was lost or recreated | Recreate renderer/refresh. No server request unless buffer is missing. |
| Layout changed only | Fit and send resize only if cols/rows changed. Reflow only for significant column mismatch or known TUI damage. |
| No local buffer, no cache | Show restore placeholder, attach visible session, wait for snapshot. |
| Raw scrollback/interim cache available | Paint interim text, attach/request full snapshot, keep state as not fully ready. |
| Full cache matches dimensions and output marker | Restore cache, settle render, optionally skip fresh snapshot if cache is pristine. |
| Full cache mismatches dimensions | Paint saved/interim fallback if available, request dimension-correct snapshot/reflow. |
| Active input or newer live output arrived after snapshot request | Skip stale snapshot, preserve live terminal, request fresh snapshot only if authoritative state is still needed. |

This table is the design expression of "do not refresh unless something changed."

## Restart Flow

Current restart restore conflates three separate milestones:

1. server recreated session processes;
2. browser received a snapshot frame;
3. xterm painted the active session completely.

The proposed flow:

1. Server restores session rows and broadcasts `server-ready` only for process
   recreation, as it does today.
2. Browser marks visible sessions `attach-requested` and sends attach with a new
   restore epoch/request id.
3. Browser may paint an interim scrollback/cache immediately, but the active
   session remains not ready.
4. A full snapshot response moves the session to `snapshot-writing`.
5. `_terminalRenderCheckFullyRendered` plus double RAF moves it to `ready`.
6. The reconnect overlay dismisses only when the active visible session is
   `ready`, or when the user is on a non-terminal panel.
7. If the request times out, CTM enters `degraded`: show best available content,
   keep a small retry/reflow affordance, and expose diagnostics. Do not silently
   clear the state after 5 seconds.

## Hidden Output

Hidden output has two valid paths:

- Hot visible cache path: the terminal object remains alive, the WS subscription
  remains active, and output is queued locally. On activation, CTM flushes the
  queue and settles locally.
- Cold restore path: the terminal is disposed or unsubscribed. On activation,
  CTM treats local bytes as non-authoritative and requests a fresh snapshot.

The coordinator should explicitly record which path a hidden session is on.
Mixing the two paths is what causes duplicate prompts, stale snapshots clobbering
live output, or missing rows.

## Split Panes

Visibility must not be modeled as `state.activeTab === id`.

A session can be:

- active in the main tab;
- visible in a split pane;
- hidden but hot;
- hidden and cold;
- represented by a non-terminal panel.

Attach, output subscription, render settling, and overlay decisions should use
`isSessionVisibleInSplit(id)` plus active tab state through one helper, for
example:

```js
sessionVisibility(id) // active | split | hidden | panel
```

## Resize And Reflow

Resize and reflow should be separated.

- Fit local xterm when the container changes.
- Send PTY resize only when cols or rows actually changed.
- Mark layout dirty only when the effective terminal dimensions changed.
- Reflow only when a full snapshot cannot be safely applied at current columns,
  or when renderer/buffer damage is detected.
- UI-only refreshes such as tab activation, model switcher repaint, queue panel
  paint, or status updates must not update running/idle activity.

## Server Coordination

Server restore currently warms snapshot cache for every restored session. That is
useful, but it can compete with the active attach request after restart.

Recommended server behavior:

- continue loading scrollback and feeding headless terminals for all restored
  sessions;
- prioritize fresh serialize requests for currently visible sessions;
- run background cache warming at low priority and limited concurrency;
- skip background warming when a fresh full cache already exists for the same
  output marker and scrollback window;
- include request id, snapshot kind, dimensions, scrollback rows, and output
  marker in snapshot diagnostics;
- optionally echo the client restore epoch/request id so stale responses can be
  rejected without inference.

## Diagnostics

Add one browser diagnostic entry point:

```js
window._ctmGetSessionViewState(id)
```

It should return:

- visibility;
- phase;
- epochs;
- last ready tuple;
- render ledger snapshot;
- snapshot cache metadata;
- pending request ids and ages;
- blockers;
- last attach/snapshot/reflow reasons;
- whether the session is allowed to dismiss loading/reconnect overlays.

This should be what render tests and manual debugging use instead of reading a
dozen private flags.

## Test Matrix

The existing rendering tests are good coverage seeds. Add or tighten tests around
these user-visible contracts:

| Scenario | Expected result |
| --- | --- |
| Restart with large active scrollback | Reconnect overlay stays until active terminal render ledger is settled and paint settled. |
| Restart while active session is still outputting | Active terminal keeps rendering new output and only reaches ready after queues drain or an authoritative snapshot lands. |
| Switch away and back with unchanged hot terminal | No attach, snapshot, or reflow request. Canvas refresh is allowed. |
| Switch away while hidden output arrives | Activation flushes local output if hot, or requests one fresh snapshot if cold. No stale snapshot clobber. |
| Toggle split pane containing a session | Visible split sessions stay subscribed and rendered even when not `activeTab`. |
| Switch to Models/Prompts/Wall-E panel and back | Existing terminal buffer is preserved. No shorter stale snapshot overwrites visible output. |
| Resize with same effective cols/rows | No PTY resize, no status activity, no reflow. |
| Resize with significant column mismatch | CTM avoids painting miswrapped snapshot and requests a dimension-correct reflow. |
| WebGL/context loss with intact xterm buffer | Renderer refresh/recreate restores pixels without server snapshot. |
| Snapshot timeout | UI enters degraded state with retry/reflow diagnostics instead of silently hiding loading state. |
| Reconnect with active and split sessions | Only visible sessions attach immediately; readiness is gated by render settle. |

## Implementation Plan

Phase 1: Document, instrument, and establish mutation ownership

- Land this design document.
- Add `window._ctmGetSessionViewState(id)` backed by existing flags and render
  ledger.
- Add logging for overlay dismissal reason and blockers.
- Add `TerminalRenderController` as the single browser-side write queue for live
  output, snapshots, cursor repair, stale-status cleanup, and lifecycle banners.
- Expose controller state from `window._ctmGetTerminalRenderState(id)` so render
  tests can prove the queue has settled.

Phase 2: Client restore coordinator and snapshot barriers

- Extend `terminal-restore-state.js` from one-shot decisions into a small pure
  reducer/helper for phases, epochs, and readiness.
- Replace restart overlay dismissal on snapshot arrival with render-ledger
  readiness.
- Replace hard 5 second loading dismissal with `ready` or `degraded`.
- Treat snapshot restore as a controller barrier: clear stale local queues,
  reject stale restore epochs, and sanitize stale Codex transient `Working`
  rows when an authoritative non-running status is newer than the busy frame.

Phase 3: Paint, fit, scroll, and repair through controller intents

- Formalize the hot-tab unchanged path using epoch tuples.
- Make attach/snapshot/reflow decisions table-driven.
- Keep existing blank-tab and real Codex tab-switch tests, then add stricter
  "zero server restore request when unchanged" assertions.
- Move force-paint, texture-atlas clear, fit/resize, scroll restore, cursor
  repair, and blank-gap compaction behind controller intent helpers so they do
  not run inside pending writes or synchronized frames.

Phase 4: Hidden output, split panes, and resize

- Record visibility state centrally.
- Separate hot hidden output from cold restore.
- Normalize resize/reflow decisions around effective cols/rows.

Phase 5: Server priority and stale response hygiene

- Prioritize visible-session serialization after restart.
- Add request ids/markers to snapshot diagnostics and stale rejection.
- Limit background cache warm concurrency.

## Current Implementation Slice

The first production slice should cover the failures that motivated this design:

- controller-routed writes for live output, snapshot replay, cursor repair,
  submitted prompt cleanup, transient Codex `Working` cleanup, blank-gap
  compaction, and crash banners. Normal live bytes still rely on xterm's parser
  FIFO; CTM does not add a second browser-side serialization layer that could
  slow or reorder typing feedback;
- deferred refresh/texture-atlas clearing while writes or synchronized frames
  are active;
- stale Codex busy-row cleanup before or immediately after idle snapshot restore;
- active-input ownership gates late snapshot paint/scroll follow-up so Claude and
  Codex prompt drafts are not moved or repainted by stale restore callbacks;
- bounded Codex semantic prompt repair only rewrites an already visible blank
  prompt row, and explicitly excludes provider image-reference path drafts so
  compact `[Image #N]` paste UX never flashes local file paths in the terminal;
- no-retry render coverage for Codex working-status cleanup, synchronized redraw
  streams, skill typing stability, stale snapshot typing, tab-switch
  idempotency, and slash picker flows.

Remaining legacy direct paint/fit/scroll paths can then be migrated behind the
same controller without changing the external behavior again.

## Open Questions

- Should CTM keep hot terminals for all recent sessions, or should the hot cache
  budget depend on memory pressure and renderer count?
- Should the coordinator live entirely in `terminal-restore-state.js`, or should
  it be split into a pure reducer plus DOM/xterm adapter functions?
- Should degraded restore be a small toolbar status, an overlay with a retry
  button, or both?
- How much scroll position should be preserved across restart versus forcing
  bottom follow for active coding sessions?

## Recommendation

Implement the coordinator incrementally. Do not rewrite terminal restore in one
large pass. The current fixes encode real production lessons. The right move is
to centralize state transitions and readiness while preserving the proven paths:
hot terminal reuse, interim scrollback fallback, dimension-mismatch reflow, stale
snapshot clobber guards, and the render ledger.
