feat: W1 batch — config, protocol, ring-buffer, origin, mock-pty (TDD)
Parallel module-builder subagents, disjoint file ownership, shared tree: - T4 src/config.ts — loadConfig+M1 origins (32 tests) - T5 src/protocol.ts — parse/serialize+SESSION_ID_RE+M7 (60 tests) - T6 src/session/ring-buffer.ts — byte-accurate, no UTF-8/ANSI split+M2 (15 tests) - T7 src/http/origin.ts — host+port match, undefined rejected (12 tests) - T3 test/helpers/mock-pty.ts — IPty double w/ emitData/emitExit (9 tests) Verified by orchestrator: tsc --noEmit clean; full suite 128 tests pass.
This commit is contained in:
65
src/session/ring-buffer.ts
Normal file
65
src/session/ring-buffer.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* src/session/ring-buffer.ts (T6) — output scrollback for reconnect replay.
|
||||
*
|
||||
* M2 (the easy-to-get-wrong part). PTY output is an opaque ANSI byte stream; we
|
||||
* must NEVER cut a multi-byte UTF-8 codepoint or an ANSI escape sequence in half,
|
||||
* or the first screen on reconnect renders as garbage — exactly the vibe-coding
|
||||
* "refresh and the Claude TUI is still there" path. So:
|
||||
*
|
||||
* - capacity is measured in REAL UTF-8 bytes (Buffer.byteLength), because
|
||||
* `string.length` counts UTF-16 code units, not bytes;
|
||||
* - eviction happens on whole append-chunk boundaries (drop the oldest chunk),
|
||||
* so no chunk is ever sliced (the trade-off is the live size floats a little
|
||||
* above/below maxBytes);
|
||||
* - snapshot() prepends a soft-reset `\x1b[0m` so that even if the oldest
|
||||
* surviving chunk started mid-attribute, the terminal is reset first.
|
||||
*
|
||||
* Implements the frozen `RingBuffer` contract in src/types.ts (read-only import).
|
||||
*/
|
||||
|
||||
import type { RingBuffer } from '../types.js';
|
||||
|
||||
const SOFT_RESET = '\x1b[0m';
|
||||
|
||||
interface Chunk {
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ring buffer holding at most ~`maxBytes` UTF-8 bytes of recent output.
|
||||
*
|
||||
* @param maxBytes capacity in real UTF-8 bytes (e.g. `cfg.scrollbackBytes`).
|
||||
*/
|
||||
export function createRingBuffer(maxBytes: number): RingBuffer {
|
||||
// Immutable-update style: each append produces a new chunk list / total rather
|
||||
// than mutating shared state in place (per coding-style immutability rule).
|
||||
let chunks: readonly Chunk[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
function append(chunk: string): void {
|
||||
if (chunk.length === 0) return; // nothing to store; keeps accounting clean
|
||||
|
||||
const bytes = Buffer.byteLength(chunk, 'utf8');
|
||||
let nextChunks: Chunk[] = [...chunks, { text: chunk, bytes }];
|
||||
let nextTotal = totalBytes + bytes;
|
||||
|
||||
// Evict whole oldest chunks until we fit — but never drop the only remaining
|
||||
// chunk, so a lone chunk larger than capacity is kept intact (cannot split).
|
||||
while (nextTotal > maxBytes && nextChunks.length > 1) {
|
||||
const oldest = nextChunks[0]!;
|
||||
nextChunks = nextChunks.slice(1);
|
||||
nextTotal -= oldest.bytes;
|
||||
}
|
||||
|
||||
chunks = nextChunks;
|
||||
totalBytes = nextTotal;
|
||||
}
|
||||
|
||||
function snapshot(): string {
|
||||
// Soft-reset first (M2 safety net), then the current buffer verbatim.
|
||||
return SOFT_RESET + chunks.map((c) => c.text).join('');
|
||||
}
|
||||
|
||||
return { append, snapshot };
|
||||
}
|
||||
Reference in New Issue
Block a user