Skip to content

[bug] Terminal has no scrollback — every snapshot is fetched then discarded except the last, and none are written while the engine is busy #432

Description

@serge-ivo

The terminal has no scrollback: every snapshot is fetched, all but the last are thrown away, and none are written while the engine is busy

Reported: the Terminal tab on a live coding agent sits at (waiting for output...), and the owner asked why output is not served from the DB and paged like chat history.

Partly it already is — the caching exists and works better than assumed. What is missing is when it writes and how much it shows.

Where (waiting for output...) comes from

agents/coder/web/src/CodingTab.tsx:352:

const newText = live || savedTerminalRef.current || "(waiting for output...)";

The placeholder means both the live capture and the DB fallback were empty. Three compounding reasons that happens:

1. Snapshots are only written while the engine is IDLE. workers/api/src/routes/coding.ts:254-258:

if (pane.trim() && runState === "idle") {
  const last = await lastTerminal(c.env, sessionId);
  if (pane.trim() !== (last ?? "").trim()) {
    await appendTimeline(c.env, {, type: "terminal", content: pane.slice(-8000) });
  }
}

The gate is deliberate and its reasoning is sound (:250-251/capture runs every 3s per open session, and a read+write per poll is a lot of D1 for a pane that is not moving). But the consequence is that a session which has been busy since it started has no snapshot at all — which is exactly a Loop run working through 40 steps. The output the owner most wants to see is the output never written.

2. Nothing is written unless someone is watching. The write lives inside the /capture handler, and /capture only runs while a session view is open and polling. Work done while the tab is closed persists nothing, so reopening shows the placeholder.

3. The fallback is cleared before it is loaded. CodingTab.tsx:417 does setSavedTerminal(""), then :420 awaits /start, then :429 awaits /timeline?full=1. Between the clear and the load, savedTerminal is "" — so the placeholder flashes on every session open, even when a snapshot exists.

The bigger finding: the history is downloaded and then discarded

?full=1 returns the entire typed timeline — every terminal row, seq-ordered (coding.ts:411-413). The client then does this (agents/coder/web/src/timeline-chat.ts:69-73):

export function lastTerminalSnapshot(payload: TimelinePayload): string {
  const terminals = (payload.timeline || []).filter((e) => e.type === "terminal");
  const last = terminals[terminals.length - 1];
  return (last?.content || last?.text || "").trim();
}

Every snapshot but the most recent is fetched over the network and thrown away in the client. So this is not a "download older on demand" gap — the data is already in memory and dropped. There is no scrollback model for the terminal at all: no pagination, no "load older", no concatenation. One pane, most recent, or nothing.

That is the direct answer to "does it show only the last few and download previous on demand like chats?"no. It shows exactly one, and there is nothing to page.

What to do — rework, in dependency order

1. Write snapshots during busy runs, not only at idle. Keep the dedup, replace the runState === "idle" gate with a throttle: persist when the pane has changed AND at most once every N seconds (N ≈ 15-30 keeps D1 volume near today's while covering long runs). The current gate optimises the case where nothing is happening and fails the case where everything is.

2. Do not clear the fallback before the reload. Keep the previous savedTerminal until the timeline resolves, or render a skeleton. One line, removes the guaranteed flash.

3. Give the terminal a real scrollback. The rows exist, are seq-ordered, and are already being fetched:

  • render the snapshots as a scrollable history (newest at the bottom), not a single pane;
  • add a Load older control paging backwards by seq;
  • add real pagination params to /timeline (before, limit) so ?full=1 stops shipping an entire session's snapshots — a long run at 8000 chars each is a large payload for one visible pane.

This is the same defect shape as #428 (chat pagination), and the same fix pattern: a seq/key-ordered cursor with an explicit hasMore. Worth doing together so one cursor convention covers both.

4. Say why it is empty. (waiting for output...) is shown for four different states: runner offline, session starting, engine busy with nothing persisted, and genuinely no output. The route already returns runnerConnected, alive, ready and runState (coding.ts:212, 218) — the console has everything needed to say "runner offline — run pags up", "engine is working, no output captured yet", or "no output". A single placeholder for four causes is why this reads as broken rather than as waiting.

Alternatives considered and rejected

  • Persist on every 3s poll. Rejected for the reason already in the code comment: a read+write per poll per open session is a lot of D1 for a static pane. The throttle in (1) gets the coverage without the write amplification.
  • Stream the pane over the relay WebSocket instead of polling. Attractive, and it would make "straight away" literal — but the relay is a request/response command channel today (callRunner), so this is a transport change, not a fix. Worth its own ticket if the polling latency is the real complaint; it does not address history, which is the substance here.
  • Keep only the last snapshot and drop the rest server-side. Rejected — it would make the current UI correct by deleting the capability being asked for.
  • Render the whole timeline without pagination. Rejected — 8000 chars per snapshot over a long session is exactly the payload problem that motivates (3).

Acceptance criteria

  • A session busy for several minutes has terminal snapshots in coding_timeline before it goes idle.
  • Reopening a session shows the last known output immediately, with no (waiting for output...) flash.
  • The Terminal view scrolls back through earlier snapshots, loading older ones on demand.
  • /timeline accepts a cursor and does not return an entire session's snapshots in one payload.
  • An empty terminal states which of the four causes applies.

Regression risk

  • Raising write frequency increases D1 volume on the hottest poll in the product; the throttle constant should be measured against a real Loop run, not guessed.
  • coding_timeline has no retention policy that I found; a scrollback that encourages long sessions makes that gap matter. Worth checking before (1) lands, or the table grows without bound.
  • lastTerminalSnapshot is used by the Co-pilot's context too — changing it to a paged model must not change what the Co-pilot reads (it wants the most recent pane, which stays correct).

Related: #428 (identical pagination-cursor gap in chat — share the convention), #247 (the tmux→child-process move that produced this pane model), #348 (why a terminal-connector pane is unmetered — the same "a pane is rendered text" constraint).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions