Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/main/sessionManager.screenGate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,29 @@ describe('SessionManager screen-frame gate', () => {
expect(write).toHaveBeenCalledExactlyOnceWith('\x1b')
await manager.kill(sessionId)
})

it('a remounted raw terminal gets the modes its evicted startup bytes set (#843, real OpenCode recording)', async () => {
const { readFileSync } = await import('node:fs')
const { resolve } = await import('node:path')
const { Terminal } = await import('@xterm/headless')
const recording = JSON.parse(readFileSync(resolve(__dirname, '../../testing/fixtures/terminal-replay-modes/opencode-1.18.31-startup.json'), 'utf8')) as Array<{ d: string }>
const preamble = recording.findIndex(chunk => chunk.d.includes('\x1b[?1049h'))
const frames = recording.slice(preamble + 1).map(chunk => chunk.d).filter(chunk => chunk.includes('\x1b[?2026h'))
const { SessionManager } = await import('./sessionManager')
const session = new FakeAgentSession()
createSession.mockImplementation(() => session)
const manager = new SessionManager()
const { sessionId } = await manager.spawn({ kind: 'claude', cwd: '/tmp/project' })
for (const chunk of recording.slice(0, preamble + 1)) session.emit('pty-data', chunk.d)
// Past the real 512 KiB agent cap, as minutes of 60 fps repaint are.
let written = 0
while (written < 600 * 1024) for (const frame of frames) { session.emit('pty-data', frame); written += frame.length }
const replay = manager.attachAgentPty(sessionId)!
const terminal = new Terminal({ cols: 120, rows: 36, allowProposedApi: true })
await new Promise<void>(done => terminal.write(replay, done))
expect(terminal.buffer.active.type).toBe('alternate')
expect(terminal.modes.mouseTrackingMode).toBe('any')
manager.detachAgentPty(sessionId)
await manager.kill(sessionId)
})
})
48 changes: 48 additions & 0 deletions src/main/sessionManager.terminalReplay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { EventEmitter } from 'node:events'
import { expect, it, vi } from 'vitest'

// #843 / #1041 review: the SHELL attach path (attachTerminal) must replay the
// modes its evicted bytes set, like the agent path. Reverting it to read()
// passed every test until this one. A full-screen program run from a shell
// (vim, htop, an OpenCode TUI started by hand) sets the alternate screen and
// mouse tracking once; 256 KiB of repaint evicts that.
const terminals = vi.hoisted(() => ({ latest: null as null | EventEmitter }))
vi.mock('@shared/runtime/terminalSession.js', () => ({
TerminalSession: class FakeTerminalSession extends EventEmitter {
constructor() { super(); terminals.latest = this }
async start(): Promise<void> { this.emit('started') }
async stop(): Promise<void> {}
write(): void {}
resize(): void {}
},
}))
vi.mock('@main/workspaceDirectory.js', () => ({
MissingWorkspaceDirectoryError: class extends Error {},
assertWorkspaceDirectoryExists: vi.fn(async () => {}),
}))
vi.mock('@main/setup/toolchain.js', () => ({ getToolPath: () => '/usr/bin/true' }))
vi.mock('@main/performance/PerformanceService.js', () => ({
performanceService: { mark: vi.fn(), record: vi.fn(), error: vi.fn(), metric: vi.fn(), span: () => ({ end: vi.fn(), fail: vi.fn() }) },
}))
vi.mock('@main/storage/feedDebugLog.js', () => ({ forgetFeedDebugSession: vi.fn() }))

it('a remounted shell terminal gets the alternate screen and mouse mode its evicted bytes set', async () => {
const { Terminal } = await import('@xterm/headless')
const { SessionManager } = await import('./sessionManager')
// No tmux: a direct PTY terminal, the case every machine without tmux runs.
const manager = new SessionManager({ isAvailable: () => false, getBinary: () => null } as never)
const { sessionId } = await manager.spawn({ kind: 'terminal', cwd: '/tmp/project' })
const shell = terminals.latest!
shell.emit('data', 'user@host project % vim notes.md\r\n')
shell.emit('data', '\x1b[?1049h\x1b[?1000h\x1b[?1006h')
// Past the 256 KiB shell cap with full-screen repaints.
const frame = '\x1b[H' + 'editing notes '.repeat(200) + '\r\n'
for (let written = 0; written < 300 * 1024; written += frame.length) shell.emit('data', frame)

const replay = manager.attachTerminal(sessionId)
const terminal = new Terminal({ cols: 120, rows: 36, allowProposedApi: true })
await new Promise<void>(done => terminal.write(replay, done))
expect(terminal.buffer.active.type).toBe('alternate')
expect(terminal.modes.mouseTrackingMode).toBe('vt200')
await manager.kill(sessionId)
})
20 changes: 11 additions & 9 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { getToolPath, refreshToolchainFromState } from '@main/setup/toolchain.js
import { resolveToolPath } from '@main/setup/binaryResolver.js'
import { updateToolPaths } from '@main/setup/setupState.js'
import { forgetFeedDebugSession } from '@main/storage/feedDebugLog.js'
import { CappedTextBuffer } from '@main/sessions/cappedTextBuffer.js'
import { TerminalReplayBuffer } from '@main/sessions/terminalReplayBuffer.js'
import { ScreenFrameGate } from '@main/sessions/screenFrameGate.js'
import type {
ConditionCustomAction,
Expand Down Expand Up @@ -641,7 +641,7 @@ export class SessionManager extends EventEmitter {
// WHY CappedTextBuffer and not a string: see src/main/sessions/
// cappedTextBuffer.ts — the string version copied the whole cap on every
// chunk and retained sliced-string parents (#726).
private readonly terminalBuffers = new Map<string, CappedTextBuffer>()
private readonly terminalBuffers = new Map<string, TerminalReplayBuffer>()
private readonly terminalAttached = new Set<string>()

// Shell activity producer (#865). Emits on a channel of its own, NOT
Expand Down Expand Up @@ -676,7 +676,7 @@ export class SessionManager extends EventEmitter {
// terminal, and agent PTYs can be noisy. Buffer in main, broadcast
// only after an attach, and let the renderer replay the buffer before
// draining live bytes.
private readonly agentPtyBuffers = new Map<string, CappedTextBuffer>()
private readonly agentPtyBuffers = new Map<string, TerminalReplayBuffer>()
private readonly agentPtyAttachCounts = new Map<string, number>()
private readonly agentPtyRestoreSizes = new Map<string, PtySize>()

Expand Down Expand Up @@ -2713,7 +2713,7 @@ export class SessionManager extends EventEmitter {
})

this.sessionSizes.set(sessionId, initialSize)
this.agentPtyBuffers.set(sessionId, new CappedTextBuffer(AGENT_PTY_BUFFER_CAP))
this.agentPtyBuffers.set(sessionId, new TerminalReplayBuffer(AGENT_PTY_BUFFER_CAP))
session.on('started', ({ projectDir }) => {
if (!ownsEntry()) return
this.markActivity(sessionId)
Expand Down Expand Up @@ -2752,7 +2752,7 @@ export class SessionManager extends EventEmitter {
this.markActivity(sessionId)
let replay = this.agentPtyBuffers.get(sessionId)
if (!replay) {
replay = new CappedTextBuffer(AGENT_PTY_BUFFER_CAP)
replay = new TerminalReplayBuffer(AGENT_PTY_BUFFER_CAP)
this.agentPtyBuffers.set(sessionId, replay)
}
replay.append(data)
Expand Down Expand Up @@ -3102,7 +3102,7 @@ export class SessionManager extends EventEmitter {
// and is replayed to the renderer on attach — see the block
// comment on terminalBuffers above for the full reasoning.
this.sessionSizes.set(sessionId, initialSize)
this.terminalBuffers.set(sessionId, new CappedTextBuffer(TERMINAL_BUFFER_CAP))
this.terminalBuffers.set(sessionId, new TerminalReplayBuffer(TERMINAL_BUFFER_CAP))

// Terminal sessions only emit started / data / exit. The 'data'
// event carries raw PTY bytes for xterm.js on the renderer side;
Expand Down Expand Up @@ -3130,7 +3130,7 @@ export class SessionManager extends EventEmitter {
// standard terminal scrollback behavior.
let replay = this.terminalBuffers.get(sessionId)
if (!replay) {
replay = new CappedTextBuffer(TERMINAL_BUFFER_CAP)
replay = new TerminalReplayBuffer(TERMINAL_BUFFER_CAP)
this.terminalBuffers.set(sessionId, replay)
}
replay.append(data)
Expand Down Expand Up @@ -3305,7 +3305,8 @@ export class SessionManager extends EventEmitter {
)
return ''
}
const buffer = this.terminalBuffers.get(sessionId)?.read() ?? ''
// replay(), not read(): the modes the evicted bytes set come first (#843).
const buffer = this.terminalBuffers.get(sessionId)?.replay() ?? ''
// Flip the attach flag in the SAME synchronous block as reading
// the buffer. JavaScript is single-threaded and event emission
// can only happen on a later tick, so nothing can sneak in.
Expand Down Expand Up @@ -3353,7 +3354,8 @@ export class SessionManager extends EventEmitter {
)
return null
}
const buffer = this.agentPtyBuffers.get(sessionId)?.read() ?? ''
// replay(), not read(): the modes the evicted bytes set come first (#843).
const buffer = this.agentPtyBuffers.get(sessionId)?.replay() ?? ''
const attachCount = this.agentPtyAttachCounts.get(sessionId) ?? 0
if (attachCount === 0) {
const currentSize = this.sessionSizes.get(sessionId)
Expand Down
20 changes: 19 additions & 1 deletion src/main/sessions/cappedTextBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ export class CappedTextBuffer {
private live = 0
readonly pieceSize: number

constructor(readonly cap: number, pieceSize?: number) {
constructor(
readonly cap: number,
pieceSize?: number,
/** Called with every span of text the cap discards, oldest first, before
* it is gone. TerminalReplayBuffer feeds these to a DEC mode tracker so
* the replay can restore the modes they set (#843). */
private readonly onEvict?: (text: string) => void,
) {
if (!(cap > 0)) throw new RangeError(`CappedTextBuffer cap must be positive, got ${cap}`)
// A sixteenth of the cap bounds overflow loss to ~6% of the replay, with
// a floor so small caps (tests, tiny terminals) do not shred every chunk.
Expand Down Expand Up @@ -80,6 +87,16 @@ export class CappedTextBuffer {
// never a slice, which would retain the whole oversized chunk (#321).
// Cutting straight from the tail offset copies each byte once.
start = surrogateSafeStart(chunk, chunk.length - this.cap)
// Everything retained so far, then the chunk's own discarded head, in
// stream order: the evict hook must see bytes in the order the
// program wrote them.
if (this.onEvict) {
for (let index = this.head; index < this.pieces.length; index += 1) {
const piece = this.pieces[index]!
if (piece.length > 0) this.onEvict(piece)
}
if (start > 0) this.onEvict(chunk.slice(0, start))
}
this.pieces = []
this.head = 0
this.live = 0
Expand Down Expand Up @@ -116,6 +133,7 @@ export class CappedTextBuffer {
this.pieces[this.head] = ''
this.head += 1
this.live -= dropped.length
this.onEvict?.(dropped)
}
}
}
Expand Down
175 changes: 175 additions & 0 deletions src/main/sessions/decModeTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// The DEC private modes a terminal replay must restore before its bytes
// (#843).
//
// WHY this exists: SessionManager replays the last N KiB of a PTY to a
// terminal that attaches later (CappedTextBuffer). A full-screen TUI writes its
// mode preamble ONCE at startup. OpenCode 1.18.31, recorded in
// testing/fixtures/terminal-replay-modes/, writes:
//
// ?1049h (alternate screen), ?2004h, ?1000h ?1002h ?1003h (mouse), ?1006h
//
// It then repaints whole frames at up to 60 fps, so the 512 KiB cap evicts
// that preamble within minutes. A freshly constructed xterm then sits on the
// NORMAL buffer with no mouse tracking while the application believes the
// opposite, and the mouse wheel reaches nobody. The pane still looks right,
// because a full-frame repaint renders the same on either buffer.
//
// WHY the state AT THE REPLAY START, fed from the EVICTED bytes: a first fix
// tracked modes as chunks passed and prepended the CURRENT modes. It was
// reverted after review (docs/superpowers/research/
// 2026-09-08-post-merge-regression-audit.md). If the retained bytes still
// contain output on the normal buffer followed by a later ?1049h, prefixing
// the current ?1049h moves that earlier output onto the alternate buffer, and
// the replay's own ?1049l then throws it away. The only correct prefix is
// what the evicted prefix left behind; the retained bytes then evolve it
// exactly as they did live.
//
// WHY a state machine and not a set of flags (the same review): mouse
// protocols are mutually exclusive in xterm. ?1000h ?1003h leaves ANY;
// ?1003h then ?1000h leaves VT200; and resetting ANY protocol code turns
// tracking off (xterm.js InputHandler.resetModePrivate). Encodings behave the
// same way. `ESC c` (RIS) resets them (see reset() for the one flag xterm
// keeps). 47, 1047 and 1049 all select the alternate buffer.
//
// Deliberately NOT tracked:
// - ?2026 (synchronized output) is per-frame. A prefix could leave the
// terminal holding every paint until a matching ?2026l that the replay
// may not contain.
// - Queries (DECRQM `$p`, DA, `?u`). They ask the terminal to ANSWER, and
// replaying them would send stale answers back to the program.

type MouseProtocol = 9 | 1000 | 1002 | 1003
type MouseEncoding = 1006 | 1016

const MOUSE_PROTOCOLS: ReadonlySet<number> = new Set([9, 1000, 1002, 1003])
// Only the encodings xterm.js implements: 1005 (UTF-8) and 1015 (urxvt) are
// logged and ignored there (#2507), so tracking them would only prefix a
// sequence that changes nothing, or wrongly replace a real SGR state.
const MOUSE_ENCODINGS: ReadonlySet<number> = new Set([1006, 1016])
const ALTERNATE_SCREEN: ReadonlySet<number> = new Set([47, 1047, 1049])
/** Simple on/off modes with xterm's defaults: application cursor keys (1),
* cursor visible (25), focus reporting (1004), bracketed paste (2004) and
* theme-change notifications (2031). OpenCode sets 2031 once at startup and
* switches its own light/dark palette on the reports; the app pushes theme
* changes into xterm, which reports them only while 2031 is on (#1041
* review). Setting it sends nothing by itself, so it is safe to prefix. */
const FLAG_DEFAULTS: ReadonlyMap<number, boolean> = new Map([[1, false], [25, true], [1004, false], [2004, false], [2031, false]])

/** An unterminated sequence longer than this is not a mode sequence and is
* dropped rather than carried forever. */
const MAX_CARRY = 64

export class DecModeTracker {
private alternate = false
private protocol: MouseProtocol | null = null
private encoding: MouseEncoding | null = null
private readonly flags = new Map(FLAG_DEFAULTS)
// An escape sequence cut by a chunk boundary. Evicted pieces arrive in
// stream order, so the next feed completes it.
private carry = ''

feed(text: string): void {
const input = this.carry + text
this.carry = ''
let index = input.indexOf('\x1b')
while (index !== -1) {
const next = input[index + 1]
if (next === undefined) {
this.carry = flatCopy(input.slice(index))
return
}
if (next === 'c') {
this.reset()
index = input.indexOf('\x1b', index + 2)
continue
}
if (next === '[' && input[index + 2] === '?') {
let end = index + 3
while (end < input.length && /[0-9;]/.test(input[end]!)) end += 1
if (end >= input.length) {
if (input.length - index <= MAX_CARRY) this.carry = flatCopy(input.slice(index))
return
}
const final = input[end]
if (final === 'h' || final === 'l') {
for (const param of input.slice(index + 3, end).split(';')) {
if (param !== '') this.apply(Number(param), final === 'h')
}
}
// An ESC that cut this sequence short starts the NEXT one: xterm
// aborts the unfinished sequence and parses the ESC afresh, so the
// search must restart ON it, not after it (#1041 review).
index = input.indexOf('\x1b', final === '\x1b' ? end : end + 1)
continue
}
if (next === '[' && input[index + 2] === undefined) {
this.carry = flatCopy(input.slice(index))
return
}
index = input.indexOf('\x1b', index + 1)
}
}

/** The unfinished escape sequence at the very end of the evicted bytes.
* Those bytes sit immediately before the retained tail, so a replay that
* puts this between the prefix and the tail is byte-faithful where the
* sequence straddles the boundary. Without it xterm prints the tail's half
* (`49h`) as text until the next eviction completes it (#1041 review). */
pendingFragment(): string {
return this.carry
}

/** The sequences that put a fresh xterm into the tracked state. Empty when
* the state is xterm's default, so a session that never set a mode replays
* byte-for-byte as before. */
prefix(): string {
let out = ''
if (this.alternate) out += '\x1b[?1049h'
if (this.protocol !== null) out += `\x1b[?${this.protocol}h`
if (this.encoding !== null) out += `\x1b[?${this.encoding}h`
for (const [mode, value] of this.flags) {
if (value !== FLAG_DEFAULTS.get(mode)) out += `\x1b[?${mode}${value ? 'h' : 'l'}`
}
return out
}

private apply(mode: number, set: boolean): void {
if (ALTERNATE_SCREEN.has(mode)) {
this.alternate = set
} else if (MOUSE_PROTOCOLS.has(mode)) {
// Resetting ANY protocol code disables tracking, as xterm.js does.
this.protocol = set ? mode as MouseProtocol : null
} else if (MOUSE_ENCODINGS.has(mode)) {
// Resetting EITHER encoding returns to the default one, whichever is
// active (xterm.js resetModePrivate: `case 1006: case 1016:`).
this.encoding = set ? mode as MouseEncoding : null
} else if (FLAG_DEFAULTS.has(mode)) {
this.flags.set(mode, set)
}
}

/**
* `ESC c` (RIS), matched to the SHIPPED xterm 6.1 beta rather than to the
* spec: it leaves the cursor-visibility flag (25) as it was, so a hidden
* cursor stays hidden (#1041 review, reproduced against the bundle). The
* replay must show what a live terminal shows.
*
* Soft reset (DECSTR, `CSI ! p`) is NOT modelled. In xterm it clears 1,
* 1004 and 2004 and shows the cursor. Only `tput init`, `tset` and `reset`
* send it (and `reset` sends RIS first); OpenCode, Codex and Claude never
* do, per a binary grep. Model it if a recording ever shows one.
*/
private reset(): void {
this.alternate = false
this.protocol = null
this.encoding = null
for (const [mode, value] of FLAG_DEFAULTS) if (mode !== 25) this.flags.set(mode, value)
}
}

// The carry is at most a few dozen characters, but a V8 slice of an evicted
// multi-megabyte chunk would keep that whole chunk alive (#321, and the same
// idiom as cappedTextBuffer.ts). Force a flat copy.
function flatCopy(text: string): string {
return Buffer.from(text, 'utf16le').toString('utf16le')
}
Loading
Loading