mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat(predictive-echo): Phase 0 codex fixtures, measurements, package scaffolding
Recorder (scripts/dev/record-codex-frames.mjs) captures real codex 0.147 TUI output through the production pipeline (tmux status-off + the codex-mode full strip from session.ts) into JSONL fixtures with keystroke injection points; analyzer replays them through @xterm/headless for the measurements in docs/predictive-echo-plan.md. Composer signature /^> /-style (U+203A), modal and wrapped rows correctly rejected, echo is unstyled default-fg, tmux delivers echo as minimal in-place deltas. Package: types.ts gains optional cursorX/cursorY, getCell, onWriteParsed, onResize (all additive); prediction-renderer.ts renders per-glyph spans keyed by prediction seq; @xterm/headless@^6.0.0 devDep for replay tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @fileoverview Replays a codex fixture (recorded by record-codex-frames.mjs)
|
||||
* through @xterm/headless and prints the measurements the predictive-echo
|
||||
* design doc records: cursor position + composer-row text at every keystroke
|
||||
* injection point, and the final screen with cursor + baseY state.
|
||||
*
|
||||
* Usage: node scripts/dev/analyze-codex-frames.mjs <fixture.jsonl>
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { Terminal } = require('@xterm/headless');
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) throw new Error('usage: analyze-codex-frames.mjs <fixture.jsonl>');
|
||||
const lines = readFileSync(file, 'utf8').trim().split('\n').map(JSON.parse);
|
||||
const meta = lines.shift();
|
||||
console.log('meta:', JSON.stringify(meta));
|
||||
|
||||
const term = new Terminal({ cols: meta.cols, rows: meta.rows, scrollback: 1000, allowProposedApi: true });
|
||||
const write = (data) => new Promise((r) => term.write(data, r));
|
||||
|
||||
const snap = () => {
|
||||
const buf = term.buffer.active;
|
||||
const row = buf.getLine(buf.baseY + buf.cursorY);
|
||||
return {
|
||||
cursorX: buf.cursorX,
|
||||
cursorY: buf.cursorY,
|
||||
baseY: buf.baseY,
|
||||
rowText: row ? row.translateToString(true) : null,
|
||||
cursorCell: row?.getCell?.(buf.cursorX)?.getChars() ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.keyAt) {
|
||||
const s = snap();
|
||||
console.log(`KEY ${JSON.stringify(line.data)} @ cursor(${s.cursorX},${s.cursorY}) baseY=${s.baseY}`);
|
||||
console.log(` row: ${JSON.stringify(s.rowText)}`);
|
||||
console.log(` cell-at-cursor: ${JSON.stringify(s.cursorCell)}`);
|
||||
} else {
|
||||
await write(line.data);
|
||||
}
|
||||
}
|
||||
|
||||
const final = snap();
|
||||
console.log('\nFINAL screen (| marks cursor row/col):');
|
||||
const buf = term.buffer.active;
|
||||
for (let y = 0; y < meta.rows; y++) {
|
||||
const line = buf.getLine(buf.baseY + y);
|
||||
let text = line ? line.translateToString(true) : '';
|
||||
if (y === final.cursorY) text = text.slice(0, final.cursorX) + '|' + text.slice(final.cursorX);
|
||||
if (text.trim()) console.log(String(y).padStart(3), JSON.stringify(text));
|
||||
}
|
||||
console.log('cursor:', JSON.stringify(final), 'viewportY:', buf.viewportY);
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @fileoverview Records real codex TUI output into JSONL fixtures for the
|
||||
* predictive-echo replay tests (packages/xterm-zerolag-input/test/codex-replay.test.ts).
|
||||
*
|
||||
* The pipeline reproduces production byte-for-byte: codex runs inside tmux
|
||||
* (status off, like tmux-manager.ts sessions) driven through a node-pty client,
|
||||
* and every chunk passes through the SAME full strip session.ts applies to
|
||||
* codex-mode output (alt-screen toggles, \x1b[3J, mouse DECSETs, with the
|
||||
* split-sequence carry). What lands in the fixture is what xterm.js receives.
|
||||
*
|
||||
* Fixture format: line 1 is a meta object {scenario, cols, rows, codexVersion,
|
||||
* recordedAt}; every following line is {delayMs, data} where delayMs is the gap
|
||||
* since the previous chunk and data is the stripped chunk. Keystroke injection
|
||||
* points are recorded as {keyAt: true, data} lines so the replay knows where
|
||||
* predictChar() calls belong.
|
||||
*
|
||||
* Usage: node scripts/dev/record-codex-frames.mjs <scenario|all> [--out <dir>]
|
||||
* Scenarios: type-hello, slash-picker, wrap, streaming-burst, paste-bracketed
|
||||
*
|
||||
* The CODEX_HOME is a throwaway temp dir with a fake auth.json; the fake key is
|
||||
* asserted absent from every recorded byte before the fixture is written.
|
||||
*/
|
||||
import pty from 'node-pty';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const FAKE_KEY = 'sk-test-123';
|
||||
const COLS = 100;
|
||||
const ROWS = 30;
|
||||
const BOOT_WAIT_MS = 4500;
|
||||
// NOT under /tmp: codex prints a "Refusing to create helper binaries under
|
||||
// temporary dir" warning that embeds the CODEX_HOME path when it lives in /tmp.
|
||||
// The repo's gitignored tmp/ avoids both the warning and the path leak.
|
||||
const SCRATCH = join(ROOT, 'tmp');
|
||||
|
||||
// Mirror of the codex-mode FULL strip in session.ts _handleTerminalOutput().
|
||||
function makeStripper() {
|
||||
let carry = '';
|
||||
return (data) => {
|
||||
data = carry + data;
|
||||
carry = '';
|
||||
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
||||
if (splitTail) {
|
||||
carry = splitTail[0];
|
||||
data = data.slice(0, -splitTail[0].length);
|
||||
}
|
||||
return data
|
||||
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
|
||||
.replace(/\x1b\[3J/g, '')
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
};
|
||||
}
|
||||
|
||||
// Each step: wait `waitMs` after the previous step, then write `keys` to the pty.
|
||||
const SCENARIOS = {
|
||||
'type-hello': [
|
||||
...'hello'.split('').map((ch, i) => ({ waitMs: i === 0 ? BOOT_WAIT_MS : 90, keys: ch })),
|
||||
{ waitMs: 1500, keys: '' },
|
||||
],
|
||||
'slash-picker': [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: '/' },
|
||||
{ waitMs: 400, keys: 'm' },
|
||||
{ waitMs: 150, keys: 'o' },
|
||||
{ waitMs: 1200, keys: '\x1b' },
|
||||
{ waitMs: 500, keys: '' },
|
||||
],
|
||||
wrap: [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: '' },
|
||||
...'the quick brown fox jumps over the lazy dog and keeps running until the composer box has to wrap this line twice over'
|
||||
.split('')
|
||||
.map((ch) => ({ waitMs: 25, keys: ch })),
|
||||
{ waitMs: 1500, keys: '' },
|
||||
],
|
||||
'streaming-burst': [
|
||||
...'hello'.split('').map((ch, i) => ({ waitMs: i === 0 ? BOOT_WAIT_MS : 40, keys: ch })),
|
||||
{ waitMs: 300, keys: '\r' },
|
||||
{ waitMs: 5000, keys: '' },
|
||||
],
|
||||
'paste-bracketed': [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: 'a' },
|
||||
{ waitMs: 90, keys: 'b' },
|
||||
{ waitMs: 400, keys: '\x1b[200~XYZpasted\x1b[201~' },
|
||||
{ waitMs: 1500, keys: '' },
|
||||
],
|
||||
// First-run trust dialog: the modal surface where typed chars must NOT be
|
||||
// predicted (the predictWhen ghost eliminator). Recorded UNTRUSTED so the
|
||||
// dialog actually appears; 'x' exercises typing at a non-composer cursor.
|
||||
'trust-modal': {
|
||||
trusted: false,
|
||||
steps: [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: 'x' },
|
||||
{ waitMs: 800, keys: '\r' },
|
||||
{ waitMs: 2500, keys: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
async function record(scenario, outDir) {
|
||||
const spec = SCENARIOS[scenario];
|
||||
if (!spec) throw new Error(`unknown scenario ${scenario}`);
|
||||
const steps = Array.isArray(spec) ? spec : spec.steps;
|
||||
const trusted = Array.isArray(spec) ? true : (spec.trusted ?? true);
|
||||
|
||||
mkdirSync(SCRATCH, { recursive: true });
|
||||
const lab = mkdtempSync(join(SCRATCH, 'codexrec-'));
|
||||
writeFileSync(join(lab, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: FAKE_KEY }));
|
||||
const workdir = mkdtempSync(join(SCRATCH, 'codexrec-work-'));
|
||||
if (trusted) {
|
||||
// Pre-trust the workdir so boot goes straight to the composer instead of
|
||||
// the first-run trust dialog (which trust-modal records deliberately).
|
||||
writeFileSync(join(lab, 'config.toml'), `[projects."${workdir}"]\ntrust_level = "trusted"\n`);
|
||||
}
|
||||
const sock = `codexrec-${process.pid}`;
|
||||
const codexVersion = execSync('codex --version', { encoding: 'utf8' }).trim();
|
||||
|
||||
const lines = [];
|
||||
const strip = makeStripper();
|
||||
let lastChunkAt = null;
|
||||
let recording = true;
|
||||
const proc = pty.spawn(
|
||||
'tmux',
|
||||
['-L', sock, '-f', '/dev/null', 'new-session', '-s', 'rec', ';', 'set', '-t', 'rec', 'status', 'off'],
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
cwd: workdir,
|
||||
env: { ...process.env, CODEX_HOME: lab, SHELL: '/bin/bash' },
|
||||
}
|
||||
);
|
||||
proc.onData((data) => {
|
||||
if (!recording) return; // teardown frames ([server exited]) stay out
|
||||
const now = performance.now();
|
||||
const stripped = strip(data);
|
||||
if (!stripped) return; // timing folds into the next chunk's delay
|
||||
lines.push({ delayMs: lastChunkAt === null ? 0 : Math.round(now - lastChunkAt), data: stripped });
|
||||
lastChunkAt = now;
|
||||
});
|
||||
|
||||
// tmux session starts with a shell; launch codex in it so the strip pipeline
|
||||
// sees the same attach-then-launch order production uses.
|
||||
await sleep(700);
|
||||
proc.write(`exec codex\r`);
|
||||
|
||||
for (const step of steps) {
|
||||
await sleep(step.waitMs);
|
||||
if (step.keys) {
|
||||
lines.push({ keyAt: true, data: step.keys });
|
||||
proc.write(step.keys);
|
||||
}
|
||||
}
|
||||
|
||||
recording = false;
|
||||
try {
|
||||
execSync(`tmux -L ${sock} kill-server`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
proc.kill();
|
||||
await sleep(200);
|
||||
|
||||
const allBytes = lines.map((l) => l.data).join('');
|
||||
if (allBytes.includes(FAKE_KEY)) throw new Error(`fixture ${scenario} leaked the fake key; NOT writing`);
|
||||
if (allBytes.includes(lab)) throw new Error(`fixture ${scenario} leaked the lab path; NOT writing`);
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const meta = { scenario, cols: COLS, rows: ROWS, codexVersion, recordedAt: new Date().toISOString() };
|
||||
const out = join(outDir, `${scenario}.jsonl`);
|
||||
writeFileSync(out, [JSON.stringify(meta), ...lines.map((l) => JSON.stringify(l))].join('\n') + '\n');
|
||||
rmSync(lab, { recursive: true, force: true });
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
console.log(`${scenario}: ${lines.length} lines -> ${out}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
const arg = process.argv[2];
|
||||
const outIdx = process.argv.indexOf('--out');
|
||||
const outDir =
|
||||
outIdx !== -1 ? process.argv[outIdx + 1] : join(ROOT, 'packages', 'xterm-zerolag-input', 'test', 'fixtures', 'codex');
|
||||
const wanted = arg === 'all' || !arg ? Object.keys(SCENARIOS) : [arg];
|
||||
for (const s of wanted) {
|
||||
await record(s, outDir);
|
||||
}
|
||||
Reference in New Issue
Block a user