diff --git a/docs/plans/2026-07-28-002-fix-terminal-replay-query-echo-plan.md b/docs/plans/2026-07-28-002-fix-terminal-replay-query-echo-plan.md
new file mode 100644
index 0000000..439912e
--- /dev/null
+++ b/docs/plans/2026-07-28-002-fix-terminal-replay-query-echo-plan.md
@@ -0,0 +1,329 @@
+---
+title: "fix: Stop replayed terminal output from echoing escape-sequence replies into the shell"
+type: fix
+status: completed
+date: 2026-07-28
+depth: standard
+---
+
+# fix: Stop replayed terminal output from echoing escape-sequence replies into the shell
+
+## Summary
+
+Every switch to the Terminal tab injects `11;rgb:0d0d/1111/1717R` (repeated) at the shell prompt. The string is xterm.js **answering** an OSC 11 background-colour query that lives permanently in the server's PTY replay buffer and is re-delivered to every freshly-mounted terminal. The fix is to frame replayed bytes distinctly from live bytes on the wire, and have the client refuse to send anything back to the PTY until replay has been fully parsed.
+
+---
+
+## Problem Frame
+
+`0d0d/1111/1717` is exactly the `#0d1117` theme background configured in `src/components/terminal/TerminalView.tsx`. The bytes do not originate in the workspace — the browser generates them.
+
+The loop:
+
+1. Something in the remote shell emitted a terminal query (`ESC ] 11 ; ? ST` — "what is your background colour?"). Prompt frameworks, `ls` colour probes, and TUIs all do this.
+2. `Session.readLoop` in `server/internal/terminal/manager.go` copies **raw** PTY bytes into a 100 KiB replay buffer. Queries are stored verbatim alongside ordinary output.
+3. `src/components/layout/CenterPanel.tsx` renders `` conditionally on `activeTab === "terminal"`, so every tab switch unmounts and remounts it. The `useEffect` in `TerminalView.tsx` builds a **new** `Terminal` and a **new** WebSocket each time.
+4. `Session.AddClient` replays the whole buffer into that fresh xterm instance.
+5. The fresh xterm cannot distinguish a replayed query from a live one. It answers via `term.onData`, which `TerminalView.tsx` forwards to PTY stdin as a `0x00` frame.
+6. bash is sitting at a prompt with readline active. It discards the unrecognised `ESC ]` introducer and inserts the printable remainder into the line buffer, which is then echoed.
+
+It recurs on every switch because the query never leaves the replay buffer. It appears doubled because two replies land per remount — React StrictMode double-invokes the effect in dev, briefly creating two xterm instances that each answer once. The trailing `R` is consistent with a second replayed query (most likely DSR `ESC [ 6 n`, whose CPR reply ends in `R`); confirming its exact source is an implementation-time detail, not a planning-time blocker.
+
+The class of bug is broader than OSC 11. xterm.js auto-answers DA1, DA2, DSR/CPR, XTVERSION, XTGETTCAP, and the OSC 10/11/12 colour queries. Any of them sitting in a replay buffer produces the same corruption. The fix must close the class, not one instance.
+
+**Symptom vs. corruption.** This is cosmetic-looking but is real stdin injection. A user who presses Enter without noticing runs `11;rgb:0d0d/1111/1717R` as a command. It is not exploitable — the injected bytes are self-generated and bounded — but it is data entering a shell without user intent.
+
+---
+
+## Requirements
+
+- **R1** — Switching to the Terminal tab must not write anything to PTY stdin.
+- **R2** — The same guarantee must hold for every path that creates a fresh terminal: page reload, a second browser tab attaching to the same session, StrictMode double-mount.
+- **R3** — The fix must cover all terminal query types, not an enumerated subset.
+- **R4** — Replay must keep working. The user still lands on the previous screen contents, not a blank terminal.
+- **R5** — Live queries must keep working. A TUI started *after* connect (vim, htop) still gets its colour/capability answers.
+- **R6** — A missing or delayed replay-boundary signal must not permanently mute the terminal.
+
+---
+
+## Key Technical Decisions
+
+### KTD1 — Server marks the replay boundary; client suppresses until it arrives
+
+**Decision.** Extend the terminal WebSocket protocol with two server→client frames: `0x02` (replay chunk) and `0x03` (replay complete). The client writes both `0x00` and `0x02` payloads to xterm, but drops everything `onData` produces until it has seen `0x03` *and* xterm has finished parsing the replayed bytes.
+
+**Rationale.** This is correct by construction. It does not require knowing which escape sequences xterm answers, so it satisfies R3 without an enumeration that goes stale when xterm adds a query handler. The boundary is authoritative rather than inferred.
+
+**Alternatives rejected:**
+
+- *Frontend-only quiet window* (drop outbound data for ~N ms after connect). One file, no protocol change — but timing-based. A large replay over a slow link outlasts the window; a fast typist loses real keystrokes. Fails R3 in the tail.
+- *Sanitize the replay buffer server-side* (strip queries before storing). No protocol change and server-only — but it is a denylist. Any query type not enumerated still leaks, which fails R3 directly. Retained as optional defence-in-depth (see Scope Boundaries).
+- *Keep `TerminalView` permanently mounted* (CSS-hide instead of unmount). Removes the remount and is a genuine UX win, but is not a fix: reload and multi-tab still replay. Routed to follow-up work.
+
+### KTD2 — Replay is written through an explicit `Client` interface, under the session lock
+
+**Decision.** Change `Session.AddClient(w io.Writer)` to accept an interface carrying three operations — live write, replay write, and replay-complete — and change `Session.clients` to be keyed by that interface.
+
+```
+Client interface {
+ io.Writer // live PTY output → 0x00
+ WriteReplay(p []byte) error // buffered output → 0x02
+ ReplayComplete() error // boundary marker → 0x03
+}
+```
+
+**Rationale.** The `0x03` marker must be emitted *before* the client is registered for live fan-out and *while* `s.mu` is still held. If the handler sent `0x03` after `AddClient` returned, live output could interleave between the replay and the marker, and the client would suppress a reply to a genuinely live query. Putting all three operations behind one interface lets `AddClient` keep the entire sequence ordered under a single lock acquisition.
+
+An optional-interface type assertion (`if rs, ok := w.(replaySink)`) would preserve the existing signature, but `AddClient` has exactly one caller (`server/internal/handler/terminal.go`), so the explicit signature change is cheaper and clearer than the implicit one.
+
+### KTD3 — Unmute on xterm's write callback, not on frame arrival
+
+**Decision.** Receiving `0x03` does not itself unmute the client. It schedules the unmute through `term.write()`'s completion callback.
+
+**Rationale.** This is the detail most likely to make a naive implementation fail silently. `term.write()` is **asynchronous** — xterm buffers input and parses it on a later tick. If the client sets `replayDone = true` the instant `0x03` arrives, xterm may not have parsed the replayed queries yet, and their replies will fire *after* the flag flipped. The bug survives the fix.
+
+`@xterm/xterm` v6 (`node_modules/@xterm/xterm/typings/xterm.d.ts:1253`) exposes `write(data: string | Uint8Array, callback?: () => void): void`, and writes are processed in order. Writing a zero-length payload on `0x03` and unmuting inside its callback guarantees every preceding replay chunk has been fully parsed first.
+
+### KTD4 — Timeout fallback so a missing `0x03` cannot brick the terminal
+
+**Decision.** Arm a timer on WebSocket open that force-unmutes after a bounded delay (~2s) if `0x03` never arrives. Clear it when the boundary is handled.
+
+**Rationale.** R6. A client talking to an older server binary would otherwise be permanently unable to type. Degrading to today's behaviour (occasional echoed reply) is strictly better than an unusable terminal.
+
+### KTD5 — `0x03` is always sent, including when the replay buffer is empty
+
+**Decision.** `AddClient` emits `ReplayComplete()` unconditionally, not only when `len(s.replay) > 0`.
+
+**Rationale.** The first connection to a fresh PTY has an empty buffer. Without an unconditional marker, that client relies solely on the KTD4 timeout and is muted for the full fallback window — the worst case for the most common first-use path.
+
+---
+
+## High-Level Technical Design
+
+### The loop as it exists today
+
+```mermaid
+sequenceDiagram
+ participant Shell as Remote shell (PTY)
+ participant Mgr as terminal.Session
+ participant Buf as replay buffer
+ participant WS as /ws/terminal
+ participant Term as xterm.js (fresh instance)
+
+ Note over Shell: earlier in the session
+ Shell->>Mgr: ESC ] 11 ; ? ST (query)
+ Mgr->>Buf: appendReplay(raw bytes, query included)
+
+ Note over Term: user switches to Terminal tab → remount
+ Term->>WS: connect
+ WS->>Mgr: AddClient
+ Mgr->>Term: 0x00 + entire replay (query re-delivered)
+ Term-->>Term: parses query, treats it as live
+ Term->>WS: 0x00 + ESC ] 11 ; rgb:0d0d/1111/1717 ST
+ WS->>Shell: written to PTY stdin
+ Note over Shell: readline drops ESC ], echoes the rest
+```
+
+### The flow after the fix
+
+```mermaid
+sequenceDiagram
+ participant Shell as Remote shell (PTY)
+ participant Mgr as terminal.Session
+ participant WS as /ws/terminal
+ participant Term as xterm.js (fresh instance)
+
+ Term->>WS: connect
+ Note over Term: replayDone = false; outbound gate CLOSED
+ WS->>Mgr: AddClient(client)
+
+ rect rgb(30,40,55)
+ Note over Mgr,Term: all three emitted under s.mu, before live registration
+ Mgr->>Term: 0x02 + replay bytes
+ Mgr->>Term: 0x03 (replay complete)
+ end
+
+ Term-->>Term: parses replay, generates query replies
+ Term--xWS: replies DROPPED (gate closed)
+ Term-->>Term: write("", cb) callback fires after parse completes
+ Note over Term: replayDone = true; gate OPEN
+
+ Shell->>Mgr: live output
+ Mgr->>Term: 0x00 + data
+ Term->>WS: 0x00 + real keystrokes / live query replies
+ WS->>Shell: PTY stdin
+```
+
+### Wire protocol after this change
+
+| Frame | Direction | Payload | Meaning |
+| --- | --- | --- | --- |
+| `0x00` | both | raw bytes | Live terminal I/O — stdout to client, stdin from client |
+| `0x01` | client → server | JSON `{cols,rows}` | Resize |
+| `0x02` | server → client | raw bytes | **New.** Replayed historical output. Render, but suppress responses |
+| `0x03` | server → client | empty | **New.** Replay complete; responses may resume |
+
+The `0x02`/`0x03` frames are server→client only. The client never emits them.
+
+---
+
+## Implementation Units
+
+### U1. Frame replayed PTY output distinctly from live output
+
+**Goal.** The server tells the client which bytes are history and when history ends.
+
+**Requirements.** R1, R2, R3, R4, R5 (server half)
+
+**Dependencies.** None
+
+**Files:**
+- `server/internal/terminal/manager.go` — modify
+- `server/internal/handler/terminal.go` — modify
+- `server/internal/terminal/manager_test.go` — create
+
+**Approach.**
+
+Introduce the `Client` interface from KTD2 in the `terminal` package and change `Session.clients` to `map[Client]bool`. Rewrite `AddClient` so that, under a single `s.mu` acquisition, it (a) writes the replay buffer via `WriteReplay` when non-empty, (b) calls `ReplayComplete()` unconditionally per KTD5, then (c) registers the client for live fan-out. A write error at any step aborts registration, matching the existing early-return behaviour.
+
+`readLoop`'s fan-out continues to use the plain `io.Writer` half — live output framing is unchanged.
+
+In the handler, generalise `wsWriter` to carry its frame prefix, and add `WriteReplay`/`ReplayComplete` methods that emit `0x02` and `0x03`. A single `wsWriter` value can serve all three operations; a per-frame prefix field is not required if the methods construct their own prefix byte.
+
+Update the wire-protocol doc comment at the top of `HandleTerminalWebSocket` to document `0x02` and `0x03`, including the server→client-only direction constraint.
+
+**Patterns to follow.**
+- `wsWriter` (`server/internal/handler/terminal.go:120`) is the existing framing shim — extend it rather than introducing a parallel type.
+- Locking discipline in `readLoop`/`AddClient` (`manager.go:86`, `manager.go:134`) — hold `s.mu` across the write-and-mutate sequence; do not release between replay and registration.
+- Go test layout follows `server/internal/handler/*_test.go` (table-free, standard library `testing`, no external assertion library).
+
+**Test scenarios.**
+- `AddClient` with a non-empty replay buffer calls `WriteReplay` with the buffer contents, then `ReplayComplete`, in that order, and only then makes the client visible to fan-out.
+- `AddClient` with an **empty** replay buffer still calls `ReplayComplete` exactly once and calls `WriteReplay` zero times.
+- Output produced by `readLoop` after registration reaches the client through the live `Write` path, never through `WriteReplay`.
+- A client whose `WriteReplay` returns an error is not registered for live fan-out, and a subsequent `readLoop` write does not reach it.
+- A client whose `ReplayComplete` returns an error is not registered for live fan-out.
+- Two clients added in sequence each receive their own full replay and their own `ReplayComplete`; the second client's replay includes output that arrived after the first client attached.
+- `appendReplay` still trims to `replayBufferSize` and preserves the most recent bytes (guard against regressing the existing ring behaviour while editing adjacent code).
+
+**Verification.** `cd server && go test ./internal/terminal/... ./internal/handler/...` passes. `go build ./...` succeeds — the `AddClient` signature change has exactly one call site, so a clean build is meaningful evidence the change is complete.
+
+---
+
+### U2. Suppress client→PTY writes until replay is fully parsed
+
+**Goal.** A freshly-mounted xterm renders history without answering any of it.
+
+**Requirements.** R1, R2, R3, R5, R6
+
+**Dependencies.** U1
+
+**Files:**
+- `src/components/terminal/TerminalView.tsx` — modify
+- `src/components/terminal/TerminalView.test.tsx` — create
+
+**Approach.**
+
+Add a `replayDone` flag scoped to the effect (a plain `let`, alongside the existing `disposed`), initialised `false`.
+
+In `onData`, return early while `replayDone` is false. Leave `onResize` alone — resize frames are not query replies and are safe to send during replay.
+
+Extend `ws.onmessage` to dispatch on the leading byte:
+- `0x00` → `term.write(payload)` (unchanged)
+- `0x02` → `term.write(payload)` — rendered identically; the frame type only governs the outbound gate
+- `0x03` → `term.write(new Uint8Array(0), () => { replayDone = true })` per KTD3, and clear the fallback timer
+
+Arm the KTD4 fallback timer in `ws.onopen`, and clear it in both the `0x03` path and the effect cleanup so a unmounted terminal cannot leave a live timer.
+
+Guard every `replayDone` assignment with the existing `disposed` check, consistent with how `onmessage` already early-returns.
+
+Note the current `data.length > 1` condition on the `0x00` branch — `0x03` carries an empty payload, so the new branch must not inherit that length guard.
+
+**Technical design** *(directional guidance, not implementation specification)*:
+
+```
+let replayDone = false
+let unmuteTimer = setTimeout(...) // armed on open, KTD4
+
+term.onData(d => {
+ if (!replayDone) return // drop query replies generated by replay
+ ws.send(0x00 + d)
+})
+
+ws.onmessage = e => {
+ if (disposed) return
+ switch (frame[0]) {
+ case 0x00: term.write(body); break
+ case 0x02: term.write(body); break // history: render, stay muted
+ case 0x03: clearTimeout(unmuteTimer)
+ term.write(EMPTY, () => { if (!disposed) replayDone = true })
+ }
+}
+```
+
+**Patterns to follow.**
+- The existing `disposed` guard convention in the same effect (`TerminalView.tsx:98`, `TerminalView.tsx:87`) — every async callback checks it before touching terminal state.
+- The existing binary framing helper shape used by `onData`/`onResize` (`TerminalView.tsx:52`, `TerminalView.tsx:63`).
+- Component test setup follows `src/components/chat/MessageBubble.test.tsx` and the jsdom harness in `src/test/setup-dom.ts`.
+
+**Test scenarios.**
+
+These require stubbing `WebSocket` and asserting on what the component *sends*. Assert on outbound frames rather than on internal flags, so the tests survive refactors of the gating mechanism.
+
+- Data emitted by `onData` before any `0x03` is received produces **zero** outbound `0x00` frames.
+- After a `0x03` frame is delivered and xterm's write callback has flushed, data emitted by `onData` produces an outbound `0x00` frame with the expected payload — i.e. normal typing is restored (R5 guard).
+- A `0x02` frame's payload is rendered to the terminal (not silently dropped) — replay still paints (R4 guard).
+- A `0x03` frame arriving while xterm still has unparsed `0x02` content does not unmute until the parse completes. Drive this by asserting the unmute is observed only after the injected write callback fires, not synchronously on frame receipt — this is the KTD3 regression that a naive implementation would ship broken.
+- The specific reported case: a `0x02` payload containing `ESC ] 11 ; ? BEL` produces **no** outbound frame containing `rgb:`. This is the literal bug reproduction and should read as such.
+- A `0x02` payload containing DSR (`ESC [ 6 n`) produces no outbound CPR frame — proves the fix is class-wide (R3) rather than OSC-specific.
+- With no `0x03` ever delivered, outbound data is still sent after the fallback interval elapses (R6). Use fake timers.
+- Resize frames (`0x01`) are still sent during replay — the gate is scoped to `onData` only.
+- Effect cleanup clears the fallback timer; unmounting before `0x03` arrives leaves no pending timer.
+
+**Verification.** `npm test` passes and `npx tsc -b --force` is clean. End-to-end: with a session whose shell has emitted a colour query, switch away from and back to the Terminal tab repeatedly — the prompt stays clean, prior scrollback still renders, and typing works immediately. Then start a TUI (e.g. `vim`) and confirm it renders with correct colours, proving live queries are still answered (R5).
+
+---
+
+## System-Wide Impact
+
+**Deploy skew.** The frontend is served by the same Go binary, so the skew window is limited to a browser tab left open across a deploy.
+
+- *Old client, new server.* The old `onmessage` only handles `0x00`, so `0x02` replay frames are ignored and the terminal reconnects blank. Degradation, not corruption; a refresh fixes it.
+- *New client, old server.* No `0x02`/`0x03` ever arrives. The KTD4 timer unmutes after the fallback interval and replay arrives as `0x00`, i.e. exactly today's behaviour. This is precisely why KTD4 is not optional.
+
+**Multi-client sessions.** One PTY fans out to N clients. Each attaching client gets its own replay and its own `0x03`; already-attached clients see nothing new. No cross-client interference.
+
+**Accepted trade-off.** Keystrokes typed during the replay window are dropped. The window is one bounded write (≤100 KiB) over a local WebSocket plus one xterm parse tick — sub-frame in practice, and strictly better than today, where those same keystrokes land in a line buffer already polluted with escape-sequence garbage.
+
+---
+
+## Scope Boundaries
+
+**In scope.** The replay→reply→stdin loop on `/ws/terminal`, on every path that constructs a fresh xterm instance.
+
+### Deferred to Follow-Up Work
+
+- **Keep `TerminalView` mounted across tab switches** (CSS-hide instead of conditional render in `src/components/layout/CenterPanel.tsx`). Removes the remount entirely — preserves scrollback, avoids reflow, drops a reconnect per switch. A real UX improvement, but not a fix: reload and multi-tab attach still replay. Worth doing on its own merits afterwards.
+- **Sanitize queries out of the replay buffer** as defence-in-depth. Redundant once the boundary fix lands; only becomes interesting if a future non-xterm client attaches.
+- **Per-client PTY sizing.** Multiple clients on one PTY currently fight over `Setsize`. Pre-existing, unrelated, out of scope.
+
+### Not Doing
+
+- Broader terminal-session rework — scrollback persistence, PTY lifecycle, reconnect semantics.
+- Converging the browser terminal onto the SSH proxy path (noted as a v2 cleanup in `CLAUDE.md`).
+
+---
+
+## Open Questions (deferred to implementation)
+
+- **Exact provenance of the trailing `R`.** Almost certainly a second replayed query — DSR/CPR is the strongest candidate. Confirm by logging the replay buffer once during implementation. It does not gate the fix: the boundary approach suppresses it regardless of which query produced it, and U2 has a test scenario covering DSR specifically.
+- **Fallback interval value.** ~2s is a starting point. Tune once the real replay-flush latency is observable; the only constraint is that it comfortably exceeds a 100 KiB write plus one xterm parse tick.
+- **Whether `wsWriter` needs a prefix field or three methods.** Shape decision best made against the actual code; either satisfies KTD2.
+
+---
+
+## Sources
+
+- Origin implementation: `docs/plans/2026-05-08-feat-terminal-devpod-connection-plan.md` — established the `0x00`/`0x01` framing this plan extends.
+- `node_modules/@xterm/xterm/typings/xterm.d.ts:1253` — `write(data, callback)` signature underpinning KTD3.
+- `CLAUDE.md` — "Terminal vs Open-in-VS-Code divergence" records that the browser terminal path is `devpod ssh` + PTY, distinct from the SSH proxy.
diff --git a/server/internal/handler/terminal.go b/server/internal/handler/terminal.go
index f0a0909..71d3228 100644
--- a/server/internal/handler/terminal.go
+++ b/server/internal/handler/terminal.go
@@ -16,8 +16,13 @@ import (
// between the client (xterm.js) and a PTY session attached to devpod ssh.
//
// Wire protocol:
-// - 0x00 + data: terminal I/O (stdin from client, stdout to client)
+// - 0x00 + data: live terminal I/O (stdin from client, stdout to client)
// - 0x01 + JSON: control messages (e.g., {"cols":80,"rows":24} for resize)
+// - 0x02 + data: replayed historical output (server → client only)
+// - 0x03: replay complete (server → client only, empty payload)
+//
+// 0x02/0x03 exist so the client can render scrollback without answering
+// terminal queries captured in it. See terminal.Client for why.
func (h *Handler) HandleTerminalWebSocket(w http.ResponseWriter, r *http.Request) {
sessionIDStr := chi.URLParam(r, "sessionID")
sessionID, err := uuid.Parse(sessionIDStr)
@@ -115,20 +120,35 @@ func (h *Handler) HandleTerminalWebSocket(w http.ResponseWriter, r *http.Request
}
}
-// wsWriter wraps a WebSocket connection as an io.Writer, prepending
-// the 0x00 data prefix to each write.
+// wsWriter adapts a WebSocket connection to terminal.Client, prefixing
+// each payload with the frame byte that identifies its kind.
type wsWriter struct {
conn *websocket.Conn
ctx context.Context
}
-func (w *wsWriter) Write(p []byte) (int, error) {
+// frame sends a single prefixed binary message.
+func (w *wsWriter) frame(prefix byte, p []byte) error {
msg := make([]byte, 1+len(p))
- msg[0] = 0x00
+ msg[0] = prefix
copy(msg[1:], p)
- err := w.conn.Write(w.ctx, websocket.MessageBinary, msg)
- if err != nil {
+ return w.conn.Write(w.ctx, websocket.MessageBinary, msg)
+}
+
+// Write sends live PTY output.
+func (w *wsWriter) Write(p []byte) (int, error) {
+ if err := w.frame(0x00, p); err != nil {
return 0, err
}
return len(p), nil
}
+
+// WriteReplay sends buffered historical output.
+func (w *wsWriter) WriteReplay(p []byte) error {
+ return w.frame(0x02, p)
+}
+
+// ReplayComplete tells the client it may resume responding to the terminal.
+func (w *wsWriter) ReplayComplete() error {
+ return w.frame(0x03, nil)
+}
diff --git a/server/internal/terminal/manager.go b/server/internal/terminal/manager.go
index e252108..67f29f0 100644
--- a/server/internal/terminal/manager.go
+++ b/server/internal/terminal/manager.go
@@ -15,13 +15,32 @@ import (
// keeping memory per terminal session bounded.
const replayBufferSize = 100 * 1024
+// Client receives PTY output. Replayed (historical) output is delivered
+// separately from live output so the client can tell the two apart.
+//
+// This distinction is load-bearing, not cosmetic. The replay buffer stores
+// raw PTY bytes, which may include terminal *queries* the remote shell
+// emitted earlier (OSC 11 background-colour, DA, DSR, ...). A terminal
+// emulator receiving those queries answers them, and the answer would be
+// written back to PTY stdin — landing in the shell's line buffer as garbage.
+// Framing replay distinctly lets the client suppress responses until the
+// replay boundary has passed.
+type Client interface {
+ // Write delivers live PTY output.
+ io.Writer
+ // WriteReplay delivers buffered historical output.
+ WriteReplay(p []byte) error
+ // ReplayComplete signals that no further replayed output will arrive.
+ ReplayComplete() error
+}
+
// Session represents a single PTY session attached to a devpod ssh process.
type Session struct {
ptmx *os.File
cmd *exec.Cmd
mu sync.Mutex
- clients map[io.Writer]bool
+ clients map[Client]bool
replay []byte // recent PTY output, replayed to new clients
done chan struct{} // closed when the reader goroutine exits
}
@@ -59,7 +78,7 @@ func (m *Manager) GetOrCreate(sessionID string, cmdFactory func() *exec.Cmd) (*S
s := &Session{
ptmx: ptmx,
cmd: cmd,
- clients: make(map[io.Writer]bool),
+ clients: make(map[Client]bool),
done: make(chan struct{}),
}
m.sessions[sessionID] = s
@@ -127,26 +146,37 @@ func (s *Session) appendReplay(data []byte) {
}
}
-// AddClient registers a writer to receive PTY output and replays the
-// recent buffer so the client doesn't land on a blank terminal.
-// The replay write happens under the session lock to keep ordering
-// consistent with concurrent readLoop fan-out.
-func (s *Session) AddClient(w io.Writer) {
+// AddClient registers a client to receive PTY output and replays the
+// recent buffer so it doesn't land on a blank terminal.
+//
+// The replay write, the replay-complete marker, and the registration all
+// happen under a single hold of the session lock. That ordering matters:
+// if the marker were sent after the lock was released, live output could
+// interleave ahead of it, and the client would suppress the response to a
+// genuinely live query.
+//
+// ReplayComplete is sent unconditionally, including when the buffer is
+// empty — a client that never receives it has no way to know replay is
+// over, and would stay muted until its own fallback timer expires.
+func (s *Session) AddClient(c Client) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.replay) > 0 {
- if _, err := w.Write(s.replay); err != nil {
+ if err := c.WriteReplay(s.replay); err != nil {
return
}
}
- s.clients[w] = true
+ if err := c.ReplayComplete(); err != nil {
+ return
+ }
+ s.clients[c] = true
}
-// RemoveClient unregisters a writer from PTY output.
-func (s *Session) RemoveClient(w io.Writer) {
+// RemoveClient unregisters a client from PTY output.
+func (s *Session) RemoveClient(c Client) {
s.mu.Lock()
defer s.mu.Unlock()
- delete(s.clients, w)
+ delete(s.clients, c)
}
// Done returns a channel that is closed when the PTY process exits.
diff --git a/server/internal/terminal/manager_test.go b/server/internal/terminal/manager_test.go
new file mode 100644
index 0000000..11433d0
--- /dev/null
+++ b/server/internal/terminal/manager_test.go
@@ -0,0 +1,251 @@
+package terminal
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "sync"
+ "testing"
+ "time"
+)
+
+// fakeClient records the calls a Session makes on it, in order, so tests can
+// assert on both the content and the sequencing of replay vs. live delivery.
+type fakeClient struct {
+ mu sync.Mutex
+
+ live [][]byte
+ replay [][]byte
+ complete int
+ calls []string // ordered call log: "replay", "complete", "live"
+
+ replayErr error
+ completeErr error
+}
+
+func (c *fakeClient) Write(p []byte) (int, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.live = append(c.live, append([]byte(nil), p...))
+ c.calls = append(c.calls, "live")
+ return len(p), nil
+}
+
+func (c *fakeClient) WriteReplay(p []byte) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.replayErr != nil {
+ return c.replayErr
+ }
+ c.replay = append(c.replay, append([]byte(nil), p...))
+ c.calls = append(c.calls, "replay")
+ return nil
+}
+
+func (c *fakeClient) ReplayComplete() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.completeErr != nil {
+ return c.completeErr
+ }
+ c.complete++
+ c.calls = append(c.calls, "complete")
+ return nil
+}
+
+func (c *fakeClient) snapshot() ([][]byte, [][]byte, int, []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.live, c.replay, c.complete, append([]string(nil), c.calls...)
+}
+
+// newTestSession wires a Session to an os.Pipe standing in for the PTY, so
+// tests exercise the real readLoop fan-out rather than a reimplementation.
+// Writing to the returned *os.File simulates shell output.
+func newTestSession(t *testing.T) (*Session, *os.File) {
+ t.Helper()
+
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("os.Pipe: %v", err)
+ }
+
+ s := &Session{
+ ptmx: r,
+ clients: make(map[Client]bool),
+ done: make(chan struct{}),
+ }
+ go s.readLoop("test")
+
+ t.Cleanup(func() {
+ w.Close()
+ <-s.done
+ r.Close()
+ })
+ return s, w
+}
+
+// emit writes to the fake PTY and waits for readLoop to fan it out.
+func emit(t *testing.T, s *Session, w *os.File, data string) {
+ t.Helper()
+ if _, err := w.Write([]byte(data)); err != nil {
+ t.Fatalf("write to fake pty: %v", err)
+ }
+ waitForReplay(t, s, len(data))
+}
+
+// waitForReplay blocks until the session's replay buffer has grown to at
+// least n bytes, so tests don't race the readLoop goroutine.
+func waitForReplay(t *testing.T, s *Session, n int) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ s.mu.Lock()
+ got := len(s.replay)
+ s.mu.Unlock()
+ if got >= n {
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for %d replay bytes", n)
+}
+
+func TestAddClientReplaysThenMarksComplete(t *testing.T) {
+ s, w := newTestSession(t)
+ emit(t, s, w, "hello from the shell")
+
+ c := &fakeClient{}
+ s.AddClient(c)
+
+ _, replay, complete, calls := c.snapshot()
+ if len(replay) != 1 || !bytes.Equal(replay[0], []byte("hello from the shell")) {
+ t.Errorf("replay = %q, want one chunk of the buffered output", replay)
+ }
+ if complete != 1 {
+ t.Errorf("ReplayComplete called %d times, want 1", complete)
+ }
+ // Ordering is the whole point: history, then the boundary, then live.
+ if len(calls) != 2 || calls[0] != "replay" || calls[1] != "complete" {
+ t.Errorf("call order = %v, want [replay complete]", calls)
+ }
+}
+
+func TestAddClientMarksCompleteWithEmptyReplayBuffer(t *testing.T) {
+ // The first client on a fresh PTY has nothing to replay, but still needs
+ // the boundary marker — otherwise it stays muted until its fallback fires.
+ s, _ := newTestSession(t)
+
+ c := &fakeClient{}
+ s.AddClient(c)
+
+ _, replay, complete, _ := c.snapshot()
+ if len(replay) != 0 {
+ t.Errorf("WriteReplay called with empty buffer: %q", replay)
+ }
+ if complete != 1 {
+ t.Errorf("ReplayComplete called %d times, want 1", complete)
+ }
+}
+
+func TestOutputAfterRegistrationGoesToLiveWrite(t *testing.T) {
+ s, w := newTestSession(t)
+ emit(t, s, w, "old")
+
+ c := &fakeClient{}
+ s.AddClient(c)
+ emit(t, s, w, "new")
+
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if live, _, _, _ := c.snapshot(); len(live) > 0 {
+ break
+ }
+ time.Sleep(time.Millisecond)
+ }
+
+ live, replay, _, calls := c.snapshot()
+ if len(live) != 1 || !bytes.Equal(live[0], []byte("new")) {
+ t.Errorf("live = %q, want one chunk %q", live, "new")
+ }
+ if len(replay) != 1 || !bytes.Equal(replay[0], []byte("old")) {
+ t.Errorf("replay = %q, want only the pre-registration output", replay)
+ }
+ if len(calls) != 3 || calls[2] != "live" {
+ t.Errorf("call order = %v, want live delivery last", calls)
+ }
+}
+
+func TestAddClientSkipsRegistrationWhenReplayFails(t *testing.T) {
+ s, w := newTestSession(t)
+ emit(t, s, w, "old")
+
+ c := &fakeClient{replayErr: errors.New("connection gone")}
+ s.AddClient(c)
+
+ if _, _, complete, _ := c.snapshot(); complete != 0 {
+ t.Errorf("ReplayComplete called after replay failure")
+ }
+ emit(t, s, w, "new")
+
+ if live, _, _, _ := c.snapshot(); len(live) != 0 {
+ t.Errorf("live output delivered to unregistered client: %q", live)
+ }
+}
+
+func TestAddClientSkipsRegistrationWhenCompleteFails(t *testing.T) {
+ s, w := newTestSession(t)
+
+ c := &fakeClient{completeErr: errors.New("connection gone")}
+ s.AddClient(c)
+ emit(t, s, w, "new")
+
+ if live, _, _, _ := c.snapshot(); len(live) != 0 {
+ t.Errorf("live output delivered to unregistered client: %q", live)
+ }
+}
+
+func TestSecondClientReplayIncludesOutputSinceFirstAttached(t *testing.T) {
+ s, w := newTestSession(t)
+ emit(t, s, w, "first")
+
+ c1 := &fakeClient{}
+ s.AddClient(c1)
+ emit(t, s, w, "second")
+
+ c2 := &fakeClient{}
+ s.AddClient(c2)
+
+ _, replay, complete, _ := c2.snapshot()
+ if len(replay) != 1 || !bytes.Equal(replay[0], []byte("firstsecond")) {
+ t.Errorf("second client replay = %q, want the full buffer to date", replay)
+ }
+ if complete != 1 {
+ t.Errorf("second client ReplayComplete called %d times, want 1", complete)
+ }
+ // The first client must not be re-replayed when a second one attaches.
+ if _, r1, n1, _ := c1.snapshot(); len(r1) != 1 || n1 != 1 {
+ t.Errorf("first client saw replay=%d complete=%d, want 1 and 1", len(r1), n1)
+ }
+}
+
+func TestAppendReplayTrimsToMostRecentBytes(t *testing.T) {
+ s := &Session{clients: make(map[Client]bool), done: make(chan struct{})}
+
+ // Marker bytes are disjoint from the filler so counting stays unambiguous.
+ const marker = "END!"
+ s.appendReplay(bytes.Repeat([]byte("a"), replayBufferSize))
+ s.appendReplay([]byte(marker))
+
+ if len(s.replay) != replayBufferSize {
+ t.Fatalf("replay length = %d, want %d", len(s.replay), replayBufferSize)
+ }
+ if !bytes.HasSuffix(s.replay, []byte(marker)) {
+ t.Errorf("replay lost the most recent bytes")
+ }
+ // The trim must drop from the front: exactly len(marker) of the original
+ // filler should be gone, not an equivalent amount from the recent end.
+ if got := bytes.Count(s.replay, []byte("a")); got != replayBufferSize-len(marker) {
+ t.Errorf("filler retained = %d bytes, want %d", got, replayBufferSize-len(marker))
+ }
+}
diff --git a/src/components/terminal/TerminalView.test.tsx b/src/components/terminal/TerminalView.test.tsx
new file mode 100644
index 0000000..0783d09
--- /dev/null
+++ b/src/components/terminal/TerminalView.test.tsx
@@ -0,0 +1,313 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render, act } from "@testing-library/react";
+
+// Declared through vi.hoisted so the vi.mock factories below — which are
+// hoisted to the top of the module — can reach them.
+const { FakeTerminal, FakeWebSocket } = vi.hoisted(() => {
+ // A stand-in for xterm.js. `write` queues its payload and completion
+ // callback instead of running it synchronously, mirroring the real
+ // terminal's async parse — the behaviour the replay gate depends on. Tests
+ // drive the parse explicitly with `flush()`.
+ class FakeTerminal {
+ static last: FakeTerminal | undefined;
+
+ cols = 80;
+ rows = 24;
+ written: string[] = [];
+ disposed = false;
+
+ private queue: Array<{ data: string; cb?: () => void }> = [];
+ private dataHandler: ((d: string) => void) | undefined;
+
+ constructor() {
+ FakeTerminal.last = this;
+ }
+
+ loadAddon() {}
+ open() {}
+ dispose() {
+ this.disposed = true;
+ }
+
+ onData(handler: (d: string) => void) {
+ this.dataHandler = handler;
+ }
+ onResize() {}
+
+ write(data: string | Uint8Array, cb?: () => void) {
+ const text =
+ typeof data === "string" ? data : new TextDecoder().decode(data);
+ this.queue.push({ data: text, cb });
+ }
+
+ /** Parse everything queued, running completion callbacks in order. */
+ flush() {
+ const pending = this.queue;
+ this.queue = [];
+ for (const { data, cb } of pending) {
+ if (data) this.written.push(data);
+ cb?.();
+ }
+ }
+
+ /** Simulate the terminal emitting bytes back — a keystroke or a reply. */
+ emitData(d: string) {
+ this.dataHandler?.(d);
+ }
+ }
+
+ class FakeWebSocket {
+ static OPEN = 1;
+ static last: FakeWebSocket | undefined;
+
+ readyState = 1;
+ binaryType = "";
+ sent: Uint8Array[] = [];
+ onopen: (() => void) | null = null;
+ onmessage: ((e: { data: ArrayBuffer }) => void) | null = null;
+ onclose: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ constructor() {
+ FakeWebSocket.last = this;
+ }
+
+ send(payload: Uint8Array) {
+ this.sent.push(payload);
+ }
+ close() {}
+ }
+
+ return { FakeTerminal, FakeWebSocket };
+});
+
+type FakeTerminal = InstanceType;
+type FakeWebSocket = InstanceType;
+
+vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
+vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { fit() {} } }));
+vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} }));
+vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
+vi.mock("@/stores/session-store", () => ({
+ useSessionStore: () => ({
+ activeSessionId: "session-1",
+ sessions: [{ id: "session-1", workspaceStatus: "ready" }],
+ }),
+}));
+
+import { TerminalView } from "./TerminalView";
+
+/** Build a server→client frame: one prefix byte plus an optional payload. */
+function frame(prefix: number, body = ""): ArrayBuffer {
+ const encoded = new TextEncoder().encode(body);
+ const buf = new Uint8Array(1 + encoded.length);
+ buf[0] = prefix;
+ buf.set(encoded, 1);
+ return buf.buffer;
+}
+
+/** Decode the outbound frames of a given prefix into strings. */
+function outbound(ws: FakeWebSocket, prefix = 0x00): string[] {
+ return ws.sent
+ .filter((p) => p[0] === prefix)
+ .map((p) => new TextDecoder().decode(p.subarray(1)));
+}
+
+function setup() {
+ render();
+ const term = FakeTerminal.last!;
+ const ws = FakeWebSocket.last!;
+ act(() => {
+ ws.onopen?.();
+ });
+ return { term, ws };
+}
+
+function deliver(ws: FakeWebSocket, prefix: number, body = "") {
+ act(() => {
+ ws.onmessage?.({ data: frame(prefix, body) });
+ });
+}
+
+// The exact reply xterm produces for an OSC 11 query against this app's theme.
+const OSC11_QUERY = "\x1b]11;?\x07";
+const OSC11_REPLY = "\x1b]11;rgb:0d0d/1111/1717\x1b\\";
+const DSR_QUERY = "\x1b[6n";
+const CPR_REPLY = "\x1b[24;1R";
+
+describe("TerminalView replay gate", () => {
+ beforeEach(() => {
+ vi.stubGlobal("WebSocket", FakeWebSocket);
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ FakeTerminal.last = undefined;
+ FakeWebSocket.last = undefined;
+ });
+
+ it("sends nothing to the PTY before the replay boundary arrives", () => {
+ const { term, ws } = setup();
+
+ act(() => term.emitData("ls -la\r"));
+
+ expect(outbound(ws)).toEqual([]);
+ });
+
+ it("resumes sending keystrokes once replay is complete and parsed", () => {
+ const { term, ws } = setup();
+
+ deliver(ws, 0x03);
+ act(() => term.flush());
+ act(() => term.emitData("ls -la\r"));
+
+ expect(outbound(ws)).toEqual(["ls -la\r"]);
+ });
+
+ it("renders replayed output so the terminal is not blank on reconnect", () => {
+ const { term, ws } = setup();
+
+ deliver(ws, 0x02, "previous scrollback");
+ act(() => term.flush());
+
+ expect(term.written).toContain("previous scrollback");
+ });
+
+ it("renders live output", () => {
+ const { term, ws } = setup();
+
+ deliver(ws, 0x03);
+ act(() => term.flush());
+ deliver(ws, 0x00, "live shell output");
+ act(() => term.flush());
+
+ expect(term.written).toContain("live shell output");
+ });
+
+ it("stays muted until the replayed bytes have actually been parsed", () => {
+ // The regression guard for the async-write trap: receiving 0x03 is not
+ // sufficient, because xterm may not have reached the queued 0x02 content
+ // yet — and its query replies fire during that parse.
+ const { term, ws } = setup();
+
+ deliver(ws, 0x02, OSC11_QUERY);
+ deliver(ws, 0x03);
+
+ // 0x03 has landed but nothing has been parsed. A reply now must not escape.
+ act(() => term.emitData(OSC11_REPLY));
+ expect(outbound(ws)).toEqual([]);
+
+ act(() => term.flush());
+ act(() => term.emitData("x"));
+ expect(outbound(ws)).toEqual(["x"]);
+ });
+
+ it("does not echo an OSC 11 colour reply triggered by replayed output", () => {
+ // The reported bug: switching to the Terminal tab pasted
+ // `11;rgb:0d0d/1111/1717` at the shell prompt.
+ const { term, ws } = setup();
+
+ deliver(ws, 0x02, `some scrollback${OSC11_QUERY}more scrollback`);
+ act(() => term.emitData(OSC11_REPLY));
+ deliver(ws, 0x03);
+ act(() => term.flush());
+
+ expect(outbound(ws).join("")).not.toContain("rgb:");
+ expect(ws.sent).toHaveLength(1); // the initial resize frame only
+ });
+
+ it("does not echo a cursor-position reply triggered by replayed output", () => {
+ // Proves the gate is class-wide rather than OSC-specific.
+ const { term, ws } = setup();
+
+ deliver(ws, 0x02, `scrollback${DSR_QUERY}`);
+ act(() => term.emitData(CPR_REPLY));
+ deliver(ws, 0x03);
+ act(() => term.flush());
+
+ expect(outbound(ws).join("")).not.toContain("R");
+ });
+
+ it("answers queries normally once replay has completed", () => {
+ // A TUI started after connect still needs its colour/capability answers.
+ const { term, ws } = setup();
+
+ deliver(ws, 0x03);
+ act(() => term.flush());
+ deliver(ws, 0x00, OSC11_QUERY);
+ act(() => term.flush());
+ act(() => term.emitData(OSC11_REPLY));
+
+ expect(outbound(ws)).toEqual([OSC11_REPLY]);
+ });
+
+ it("unmutes on the fallback timer when no boundary frame ever arrives", () => {
+ // An older server never sends 0x03; a permanently muted terminal would be
+ // a worse failure than the bug this fixes.
+ vi.useFakeTimers();
+ const { term, ws } = setup();
+
+ act(() => term.emitData("early"));
+ expect(outbound(ws)).toEqual([]);
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ act(() => term.emitData("late"));
+
+ expect(outbound(ws)).toEqual(["late"]);
+ });
+
+ it("disarms the fallback once a replay frame proves the server is current", () => {
+ // A slow link delivering a large replay must not trip the timer and open
+ // the gate mid-replay — the very thing the gate exists to prevent.
+ vi.useFakeTimers();
+ const { term, ws } = setup();
+
+ deliver(ws, 0x02, "a lot of scrollback");
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+ act(() => term.emitData(OSC11_REPLY));
+ expect(outbound(ws)).toEqual([]);
+
+ // …and the boundary still unmutes normally when it eventually lands.
+ deliver(ws, 0x03);
+ act(() => term.flush());
+ act(() => term.emitData("x"));
+ expect(outbound(ws)).toEqual(["x"]);
+ });
+
+ it("still sends resize frames while muted", () => {
+ // The gate is scoped to onData; resize is not a query reply.
+ const { ws } = setup();
+
+ const resizes = ws.sent.filter((p) => p[0] === 0x01);
+ expect(resizes).toHaveLength(1);
+ expect(JSON.parse(new TextDecoder().decode(resizes[0].subarray(1)))).toEqual({
+ cols: 80,
+ rows: 24,
+ });
+ });
+
+ it("clears the fallback timer on unmount", () => {
+ vi.useFakeTimers();
+ const { unmount } = render();
+ const ws = FakeWebSocket.last!;
+ act(() => {
+ ws.onopen?.();
+ });
+
+ unmount();
+ // An un-cleared timer would fire against a disposed terminal.
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/src/components/terminal/TerminalView.tsx b/src/components/terminal/TerminalView.tsx
index ac88e6b..7d3d4c7 100644
--- a/src/components/terminal/TerminalView.tsx
+++ b/src/components/terminal/TerminalView.tsx
@@ -18,6 +18,28 @@ export function TerminalView() {
let disposed = false;
let ws: WebSocket | null = null;
+ // The server replays recent PTY output to every newly-attached client so
+ // the terminal isn't blank on reconnect. That buffer can contain terminal
+ // *queries* the remote shell emitted earlier (OSC 11 background-colour,
+ // DA, DSR). xterm can't tell a replayed query from a live one and answers
+ // it — and the answer would go straight to PTY stdin, where the shell
+ // echoes it as garbage at the prompt.
+ //
+ // So: drop everything xterm produces until the server's replay-complete
+ // frame (0x03) arrives AND xterm has finished parsing the replayed bytes.
+ // Both halves matter — term.write() is async, so unmuting the moment the
+ // frame lands would still let replay-triggered replies escape.
+ let replayDone = false;
+ let unmuteTimer: ReturnType | undefined;
+
+ // Fallback for an older server that never sends 0x03 at all. Unmuting
+ // late is a cosmetic regression; staying muted forever is an unusable
+ // terminal. It is disarmed as soon as any replay frame proves the server
+ // speaks this protocol — otherwise a slow link delivering a large replay
+ // could trip the timer and open the gate mid-replay, which is the exact
+ // failure the gate exists to prevent.
+ const REPLAY_FALLBACK_MS = 2000;
+
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
@@ -50,6 +72,7 @@ export function TerminalView() {
// Send keystrokes to WebSocket with 0x00 prefix
term.onData((data) => {
+ if (!replayDone) return;
if (ws && ws.readyState === WebSocket.OPEN) {
const encoded = new TextEncoder().encode(data);
const payload = new Uint8Array(1 + encoded.length);
@@ -85,6 +108,9 @@ export function TerminalView() {
ws.onopen = () => {
if (disposed) { ws?.close(); return; }
+ unmuteTimer = setTimeout(() => {
+ if (!disposed) replayDone = true;
+ }, REPLAY_FALLBACK_MS);
// Send initial terminal size
const resize = JSON.stringify({ cols: term.cols, rows: term.rows });
const encoded = new TextEncoder().encode(resize);
@@ -97,13 +123,36 @@ export function TerminalView() {
ws.onmessage = (event) => {
if (disposed) return;
const data = new Uint8Array(event.data as ArrayBuffer);
- if (data.length > 1 && data[0] === 0x00) {
- term.write(data.subarray(1));
+ if (data.length < 1) return;
+
+ switch (data[0]) {
+ case 0x02:
+ // Replayed output renders exactly like live output — the frame type
+ // only decides whether we're allowed to answer it. Its arrival also
+ // proves the server speaks this protocol, so the fallback can go.
+ clearTimeout(unmuteTimer);
+ if (data.length > 1) term.write(data.subarray(1));
+ break;
+ case 0x00: // live output
+ if (data.length > 1) term.write(data.subarray(1));
+ break;
+ case 0x03: {
+ // Replay boundary. Queue a zero-length write so the callback lands
+ // behind every 0x02 chunk already queued — xterm processes writes
+ // in order, so this fires only once the replay has been parsed and
+ // any queries in it have already been answered into the void.
+ clearTimeout(unmuteTimer);
+ term.write("", () => {
+ if (!disposed) replayDone = true;
+ });
+ break;
+ }
}
};
return () => {
disposed = true;
+ clearTimeout(unmuteTimer);
resizeObserver.disconnect();
if (ws) {
ws.onmessage = null;