From 7a3028d09242eb69d8fc4f6e6c3ab144cc1a385b Mon Sep 17 00:00:00 2001 From: onepunk <97543652+onepunk@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:10:58 +0100 Subject: [PATCH 1/2] Experiment with staggered Matrix glyph reveals for fresh text --- README.md | 4 +++- src/flicker.js | 47 +++++++++++++++++++++++++++++++++++++++++++++ src/return.js | 34 ++++++-------------------------- src/session.js | 24 +++++++++++++---------- test/matrix.test.js | 31 +++++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 40 deletions(-) create mode 100644 src/flicker.js diff --git a/README.md b/README.md index 1549e23..9a3fffc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Matrix CLI -Matrix-style code rain for **Codex CLI** and **Claude Code**. Dim background streams and brighter, faster foreground trails add depth while your agent works. The visible text falls into the rain when you switch views. Returning to the CLI clears the rain in staggered columns as its text resolves back into place. +**`text-flicker` experiment:** fresh output resolves from random green Matrix characters, with independent timing per character over roughly half a second. Returning from rain uses the same effect. Prompts and approval dialogs remain immediately readable. + +Matrix-style code rain for **Codex CLI** and **Claude Code**. Dim background streams and brighter, faster foreground trails add depth while your agent works. The visible text falls into the rain when you switch views. Returning to the CLI reveals its text through a brief flicker of green Matrix characters. ## Install diff --git a/src/flicker.js b/src/flicker.js new file mode 100644 index 0000000..3678ba3 --- /dev/null +++ b/src/flicker.js @@ -0,0 +1,47 @@ +import { renderScreen } from './screen.js'; +const glyphs = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ<>[]{}/*+=ハミヒーウシナモニサワツオリ'; + +export class TextFlicker { + constructor(random = Math.random, duration = 500) { + this.random = random; + this.duration = duration; + this.previous = new Map(); + this.animations = new Map(); + } + reset() { this.previous.clear(); this.animations.clear(); } + settle() { this.animations.clear(); } + get active() { return this.animations.size > 0; } + frame(term, offset = 0, now = performance.now(), enabled = true) { + let out = renderScreen(term,offset); + const buffer = term.buffer.active; + const current = new Map(); + for (let y=0; y= animation.until)) this.animations.delete(key); + else if (animation) { + const glyph = glyphs[(animation.seed+Math.floor(now/60))%glyphs.length]; + const colour = animation.until-now < 90 ? 195 : 46; + out += `\x1b[${y+1};${x+1}H\x1b[0;38;5;${colour}m${glyph}${cell.getWidth()===2 ? ' ' : ''}`; + } + } + } + for (const key of this.animations.keys()) if (!current.has(key)) this.animations.delete(key); + this.previous = current; + out += '\x1b[0m'; + if (!offset) out += `\x1b[${buffer.cursorY+1};${Math.min(term.cols,buffer.cursorX+1)}H\x1b[?25h`; + return out; + } +} diff --git a/src/return.js b/src/return.js index 6d6dbfa..4f409d3 100644 --- a/src/return.js +++ b/src/return.js @@ -1,37 +1,15 @@ -import { renderScreen } from './screen.js'; +import { TextFlicker } from './flicker.js'; +// Experimental return: randomly staggered characters resolve from Matrix glyphs. export class ReturnTransition { - constructor(now = performance.now(), duration = 850) { + constructor(now = performance.now(), duration = 500, flicker = new TextFlicker(Math.random,duration)) { this.started = now; this.duration = duration; + this.flicker = flicker; + this.flicker.reset(); } done(now = performance.now()) { return now - this.started >= this.duration; } frame(term, rain, now = performance.now()) { - if (this.done(now)) return renderScreen(term); - const progress = Math.max(0,(now-this.started)/this.duration); - // Keep the remaining rain moving while staggered columns resolve top to bottom. - rain.particles = []; - rain.frame(term.cols,term.rows,'',now); - let out = renderScreen(term) + '\x1b[?25l'; - const buffer = term.buffer.active; - for (let y=0; y= edge) { - for (let dx=0;dx edge-2 && cell?.getChars().trim()) { - // A brief green highlight settles into the CLI's original colours. - out += `\x1b[${y+1};${x+1}H\x1b[0;38;5;83m${cell.getChars()}`; - } - } - } - return out+'\x1b[0m'; + return this.flicker.frame(term,0,now); } } diff --git a/src/session.js b/src/session.js index 4ec0f38..7de2f8c 100644 --- a/src/session.js +++ b/src/session.js @@ -1,10 +1,11 @@ import pty from 'node-pty'; +import { TextFlicker } from './flicker.js'; import { ReturnTransition } from './return.js'; import { InputParser } from './input.js'; import xterm from '@xterm/headless'; import { detectState, ViewState } from './state.js'; import { Rain } from './rain.js'; -import { renderScreen, screenLines, captureScreen } from './screen.js'; +import { screenLines, captureScreen } from './screen.js'; export async function runSession(command, args, { demo = false } = {}) { if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('An interactive terminal is required.'); @@ -12,6 +13,7 @@ export async function runSession(command, args, { demo = false } = {}) { const term = new xterm.Terminal({ ...size(), allowProposedApi: true, scrollback: 5000 }); const view = new ViewState(); const rain = new Rain(); + const flicker = new TextFlicker(); const parser = new InputParser(); let discardPaste = false, inputTimer; let dirty = true, closed = false, offset = 0, pending = 0, exitEvent; @@ -33,11 +35,12 @@ export async function runSession(command, args, { demo = false } = {}) { if (!showingRain) rain.enter(visibleCells); showingRain = true; process.stdout.write(rain.frame(term.cols,term.rows,`${command} working`)); - } else if (dirty || showingRain || returning) { - if (showingRain && !offset) returning = new ReturnTransition(performance.now(), view.state === 'attention' ? 350 : 850); + } else if (dirty || showingRain || returning || flicker.active) { + if (view.state === 'attention') { returning = undefined; flicker.settle(); } + if (showingRain && !offset && view.state !== 'attention') returning = new ReturnTransition(performance.now(),500,flicker); showingRain = false; if (returning?.done()) returning = undefined; - process.stdout.write(returning ? returning.frame(term, rain) : renderScreen(term, offset)); + process.stdout.write(returning ? returning.frame(term, rain) : flicker.frame(term, offset, performance.now(), view.state !== 'attention')); visibleCells = captureScreen(term, offset); dirty = false; } @@ -46,7 +49,7 @@ export async function runSession(command, args, { demo = false } = {}) { const done = new Promise(resolve => { resolveDone = resolve; }); const finish = (code = 0) => { if (closed) return; - returning = undefined; showingRain = false; + flicker.settle(); returning = undefined; showingRain = false; if (!demo) { view.reveal(); offset = 0; dirty = true; paint(); } const finalText = demo ? '' : screenLines(term).map(line => line.trimEnd()).join('\r\n').trimEnd(); closed = true; @@ -68,7 +71,7 @@ export async function runSession(command, args, { demo = false } = {}) { if (event.type === 'text') { input(event.data); continue; } if (event.type === 'paste-start') { const autoReturn = returning && !view.peek; - if (returning) { returning = undefined; dirty = true; paint(); } + if (returning || flicker.active) { flicker.settle(); returning = undefined; dirty = true; paint(); } discardPaste = view.rain || offset > 0 || Boolean(autoReturn); if (discardPaste) { view.reveal(); offset = 0; dirty = true; paint(); } } @@ -85,10 +88,11 @@ export async function runSession(command, args, { demo = false } = {}) { function input(chunk) { const data = chunk.toString('utf8'); if (demo) { if (data.includes('\x03') || data === 'q') finish(); return; } - if (returning) { - returning = undefined; dirty = true; paint(); + if (returning || flicker.active) { + const autoReturn = returning && !view.peek; + flicker.settle(); returning = undefined; dirty = true; paint(); // First input during an automatic reveal finishes it without answering unseen prompts. - if (!view.peek && data !== '\x03' && data !== '\x1d') { view.reveal(); return; } + if (autoReturn && data !== '\x03' && data !== '\x1d') { view.reveal(); return; } } if (data === '\x1d') { offset = 0; view.toggle(); dirty = true; paint(); return; } if (data === '\x1b[5;2~' || data === '\x1b[6;2~') { @@ -104,7 +108,7 @@ export async function runSession(command, args, { demo = false } = {}) { if (/[\r\n]/.test(data)) view.submitted(); child.write(data); } - function resize() { if (closed) return; returning = undefined; const s = size(); term.resize(s.cols,s.rows); child?.resize(s.cols,s.rows); dirty = true; paint(); } + function resize() { if (closed) return; flicker.settle(); returning = undefined; const s = size(); term.resize(s.cols,s.rows); child?.resize(s.cols,s.rows); dirty = true; paint(); } process.stdin.on('data', receive); process.stdout.on('resize', resize); process.on('SIGTERM', terminate); process.on('SIGHUP', hangup); process.on('SIGINT', interrupt); const timer = setInterval(paint, 60); diff --git a/test/matrix.test.js b/test/matrix.test.js index 366eae6..b22b695 100644 --- a/test/matrix.test.js +++ b/test/matrix.test.js @@ -8,6 +8,7 @@ import xterm from '@xterm/headless'; import { detectState, ViewState } from '../src/state.js'; import { renderScreen, screenLines, captureScreen } from '../src/screen.js'; import { Rain } from '../src/rain.js'; +import { TextFlicker } from '../src/flicker.js'; import { ReturnTransition } from '../src/return.js'; import { InputParser } from '../src/input.js'; const write = (term, data) => new Promise(resolve => term.write(data, resolve)); @@ -318,9 +319,10 @@ test('return transition resolves current output and restores Unicode, colours an const transition = new ReturnTransition(0); await write(target,'\x1b[?7l'+transition.frame(source,rain,400)); assert.equal(target.buffer.active.baseY,0); - assert.ok(screenLines(target)[0].includes('Review complete 界')); + assert.ok(transition.flicker.active); await write(source,'\r\nReady for your next prompt.'); await write(target,transition.frame(source,rain,850)); + await write(target,transition.frame(source,rain,1400)); assert.deepEqual(screenLines(target).map(s=>s.trimEnd()),screenLines(source).map(s=>s.trimEnd())); assert.equal(target.buffer.active.getLine(0).getCell(0).getFgColor(),1); assert.equal(target.buffer.active.cursorY,source.buffer.active.cursorY); @@ -341,3 +343,30 @@ test('coalesced toggle keys are handled separately outside bracketed paste', () {type:'paste-end',data:'\x1b[201~'}, ]); }); + +test('fresh text staggers independently, settles within 500ms and never restarts unchanged text', async () => { + const source = new xterm.Terminal({cols:40,rows:10,allowProposedApi:true}); + const target = new xterm.Terminal({cols:40,rows:10,allowProposedApi:true}); + let n = 0; + const flicker = new TextFlicker(() => (++n % 4) / 4); + try { + await write(source,'Hello 界\r\n> '); + await write(target,flicker.frame(source,0,0)); + const count = flicker.animations.size; + assert.ok(count > 1); + assert.equal(screenLines(target)[1].trimEnd(),'>'); + await write(target,flicker.frame(source,0,300)); + assert.ok(flicker.animations.size > 0 && flicker.animations.size < count); + await write(target,flicker.frame(source,0,500)); + assert.equal(flicker.active,false); + assert.deepEqual(screenLines(target).map(s=>s.trimEnd()),screenLines(source).map(s=>s.trimEnd())); + flicker.frame(source,0,600); + assert.equal(flicker.active,false); + await write(source,'\x1b[1;9Hnew\x1b[2;3H'); + flicker.frame(source,0,700); + assert.equal(flicker.animations.size,3); + await write(target,flicker.frame(source,0,720,false)); + assert.equal(flicker.active,false); + assert.deepEqual(screenLines(target).map(s=>s.trimEnd()),screenLines(source).map(s=>s.trimEnd())); + } finally { source.dispose(); target.dispose(); } +}); From 7e25e9680b80f19779a8e864ed19fa29d3144752 Mon Sep 17 00:00:00 2001 From: onepunk <97543652+onepunk@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:38:40 +0100 Subject: [PATCH 2/2] Preserve flicker state during scrolling and add effect flags --- README.md | 12 +++++++ src/cli.js | 14 ++++++--- src/flicker.js | 46 ++++++++++++++++++++++----- src/options.js | 15 +++++++++ src/session.js | 17 +++++----- test/matrix.test.js | 77 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 src/options.js diff --git a/README.md b/README.md index 9a3fffc..30faa5b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ matrix --demo Arguments pass through to the selected CLI, for example `matrix claude --continue` or `matrix codex resume --last`. Uses your existing agent configuration and account; no wrapper account or telemetry. Agent usage charges still apply. +## Effects + +Disable either effect independently, or combine both flags: + +```sh +matrix claude --no-rain +matrix codex --no-text-flicker +matrix claude --no-rain --no-text-flicker +``` + +These wrapper flags work before or after the agent name and are not forwarded to the agent. Other arguments pass through unchanged; arguments after `--` are always passed through literally. With `--no-rain`, Ctrl+] does not enable rain. With `--no-text-flicker`, text appears immediately, including when returning from rain. + ## Controls | Key | Action | diff --git a/src/cli.js b/src/cli.js index aefd4c2..9d68522 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,14 +1,20 @@ #!/usr/bin/env node import { runSession } from './session.js'; -const [command, ...args] = process.argv.slice(2); +import { parseOptions } from './options.js'; +const { command, args, options } = parseOptions(process.argv.slice(2)); if (!command || command === '--help' || command === '-h') { console.log(`Matrix CLI — code rain for coding agents Usage: - matrix codex [codex arguments...] - matrix claude [claude arguments...] + matrix [options] codex [codex arguments...] + matrix [options] claude [claude arguments...] matrix --demo +Options (before or after the agent name): + --no-rain Disable rain, including Ctrl+] toggling + --no-text-flicker Show text immediately without character flicker + -- Pass remaining arguments unchanged to the agent + Controls: Ctrl+] Toggle rain / live terminal Any key during rain Reveal terminal (key consumed; Ctrl+C also interrupts) @@ -20,6 +26,6 @@ Automatic mode follows visible interrupt indicators; unknown states stay visible else if (!['codex', 'claude', '--demo'].includes(command)) { console.error(`Unknown agent: ${command}. Use matrix codex or matrix claude.`); process.exitCode = 2; } else { - try { process.exitCode = await runSession(command, args, { demo: command === '--demo' }); } + try { process.exitCode = await runSession(command, args, { ...options, demo: command === '--demo' }); } catch (error) { console.error(`matrix: ${error.message}`); process.exitCode = 1; } } diff --git a/src/flicker.js b/src/flicker.js index 3678ba3..87156fc 100644 --- a/src/flicker.js +++ b/src/flicker.js @@ -1,20 +1,49 @@ import { renderScreen } from './screen.js'; const glyphs = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ<>[]{}/*+=ハミヒーウシナモニサワツオリ'; +// Match unchanged lines in order, including output moved by a full-screen redraw. +// An ordered match keeps repeated lines distinct instead of sharing animation state. +function matchLines(previous, current) { + const lengths = Array.from({length: previous.length+1}, () => new Uint16Array(current.length+1)); + for (let i=previous.length-1;i>=0;i--) for (let j=current.length-1;j>=0;j--) { + lengths[i][j] = previous[i].text === current[j].text + ? 1+lengths[i+1][j+1] : Math.max(lengths[i+1][j],lengths[i][j+1]); + } + const matches = new Map(); + let i=0,j=0; + while (i= lengths[i][j+1]) i++; + else j++; + } + return matches; +} + export class TextFlicker { constructor(random = Math.random, duration = 500) { this.random = random; this.duration = duration; this.previous = new Map(); + this.lines = []; + this.bufferType = undefined; this.animations = new Map(); } - reset() { this.previous.clear(); this.animations.clear(); } + reset() { this.previous.clear(); this.animations.clear(); this.lines = []; } settle() { this.animations.clear(); } get active() { return this.animations.size > 0; } frame(term, offset = 0, now = performance.now(), enabled = true) { let out = renderScreen(term,offset); const buffer = term.buffer.active; const current = new Map(); + const nextAnimations = new Map(); + const lines = []; + for (let y=0;y= animation.until)) this.animations.delete(key); - else if (animation) { + if (eligible && animation && now < animation.until) { + nextAnimations.set(key,animation); const glyph = glyphs[(animation.seed+Math.floor(now/60))%glyphs.length]; const colour = animation.until-now < 90 ? 195 : 46; out += `\x1b[${y+1};${x+1}H\x1b[0;38;5;${colour}m${glyph}${cell.getWidth()===2 ? ' ' : ''}`; } } } - for (const key of this.animations.keys()) if (!current.has(key)) this.animations.delete(key); + this.animations = nextAnimations; + this.lines = lines; + this.bufferType = buffer.type; this.previous = current; out += '\x1b[0m'; if (!offset) out += `\x1b[${buffer.cursorY+1};${Math.min(term.cols,buffer.cursorX+1)}H\x1b[?25h`; diff --git a/src/options.js b/src/options.js new file mode 100644 index 0000000..48544d1 --- /dev/null +++ b/src/options.js @@ -0,0 +1,15 @@ +// Wrapper flags may appear before or after the agent. After --, arguments are literal. +export function parseOptions(argv) { + const options = { rain: true, textFlicker: true }; + const positional = []; + let literal = false; + for (const arg of argv) { + if (literal) positional.push(arg); + else if (arg === '--') { literal = true; positional.push(arg); } + else if (arg === '--no-rain') options.rain = false; + else if (arg === '--no-text-flicker') options.textFlicker = false; + else positional.push(arg); + } + const [command, ...args] = positional; + return { command, args, options }; +} diff --git a/src/session.js b/src/session.js index 7de2f8c..2e89985 100644 --- a/src/session.js +++ b/src/session.js @@ -5,9 +5,10 @@ import { InputParser } from './input.js'; import xterm from '@xterm/headless'; import { detectState, ViewState } from './state.js'; import { Rain } from './rain.js'; -import { screenLines, captureScreen } from './screen.js'; +import { renderScreen, screenLines, captureScreen } from './screen.js'; -export async function runSession(command, args, { demo = false } = {}) { +export async function runSession(command, args, { demo = false, rain: rainEnabled = true, textFlicker = true } = {}) { + if (demo && !rainEnabled) throw new Error('--demo requires rain; remove --no-rain.'); if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('An interactive terminal is required.'); const size = () => ({ cols: Math.max(2, process.stdout.columns || 80), rows: Math.max(3, process.stdout.rows || 24) }); const term = new xterm.Terminal({ ...size(), allowProposedApi: true, scrollback: 5000 }); @@ -30,17 +31,17 @@ export async function runSession(command, args, { demo = false } = {}) { if (closed || process.stdout.writableLength > 65536) return; view.tick(); if (demo) { if (dirty) process.stdout.write(rain.frame(term.cols,term.rows,'MATRIX DEMO — Ctrl+C exits')); return; } - if (view.rain && !offset) { + if (rainEnabled && view.rain && !offset) { returning = undefined; if (!showingRain) rain.enter(visibleCells); showingRain = true; process.stdout.write(rain.frame(term.cols,term.rows,`${command} working`)); } else if (dirty || showingRain || returning || flicker.active) { if (view.state === 'attention') { returning = undefined; flicker.settle(); } - if (showingRain && !offset && view.state !== 'attention') returning = new ReturnTransition(performance.now(),500,flicker); + if (textFlicker && showingRain && !offset && view.state !== 'attention') returning = new ReturnTransition(performance.now(),500,flicker); showingRain = false; if (returning?.done()) returning = undefined; - process.stdout.write(returning ? returning.frame(term, rain) : flicker.frame(term, offset, performance.now(), view.state !== 'attention')); + process.stdout.write(!textFlicker ? renderScreen(term, offset) : returning ? returning.frame(term, rain) : flicker.frame(term, offset, performance.now(), view.state !== 'attention')); visibleCells = captureScreen(term, offset); dirty = false; } @@ -72,7 +73,7 @@ export async function runSession(command, args, { demo = false } = {}) { if (event.type === 'paste-start') { const autoReturn = returning && !view.peek; if (returning || flicker.active) { flicker.settle(); returning = undefined; dirty = true; paint(); } - discardPaste = view.rain || offset > 0 || Boolean(autoReturn); + discardPaste = (rainEnabled && view.rain) || offset > 0 || Boolean(autoReturn); if (discardPaste) { view.reveal(); offset = 0; dirty = true; paint(); } } if (!discardPaste) child?.write(event.data); @@ -94,13 +95,13 @@ export async function runSession(command, args, { demo = false } = {}) { // First input during an automatic reveal finishes it without answering unseen prompts. if (autoReturn && data !== '\x03' && data !== '\x1d') { view.reveal(); return; } } - if (data === '\x1d') { offset = 0; view.toggle(); dirty = true; paint(); return; } + if (data === '\x1d') { if (!rainEnabled) return; offset = 0; view.toggle(); dirty = true; paint(); return; } if (data === '\x1b[5;2~' || data === '\x1b[6;2~') { view.reveal(); offset = Math.max(0, Math.min(term.buffer.active.baseY, offset + (data === '\x1b[5;2~' ? 1 : -1) * Math.max(1, term.rows - 2))); dirty = true; paint(); return; } - if (view.rain || offset) { + if ((rainEnabled && view.rain) || offset) { view.reveal(); offset = 0; dirty = true; paint(); // Reveal first. Never let a blind keystroke approve a hidden dialog. if (data !== '\x03') return; diff --git a/test/matrix.test.js b/test/matrix.test.js index b22b695..d908fa2 100644 --- a/test/matrix.test.js +++ b/test/matrix.test.js @@ -370,3 +370,80 @@ test('fresh text staggers independently, settles within 500ms and never restarts assert.deepEqual(screenLines(target).map(s=>s.trimEnd()),screenLines(source).map(s=>s.trimEnd())); } finally { source.dispose(); target.dispose(); } }); + +test('redrawing scrolled output only flickers new text and preserves in-flight timing', async () => { + const source = new xterm.Terminal({cols:40,rows:10,allowProposedApi:true}); + const flicker = new TextFlicker(() => .9); + const draw = lines => write(source,'\x1b[2J\x1b[H'+lines.join('\r\n')+'\x1b[10;1H'); + try { + await write(source,'\x1b[?1049h'); + await draw(['First line','Second line','Third line']); + flicker.frame(source,0,0); + flicker.frame(source,0,500); + await draw(['Second line','Third line','Fresh output']); + flicker.frame(source,0,600); + assert.ok(flicker.active); + assert.ok([...flicker.animations.keys()].every(key=>key.startsWith('alternate:2:'))); + const deadlines = [...flicker.animations.values()].map(a=>a.until); + await draw(['Third line','Fresh output','Newest output']); + flicker.frame(source,0,700); + assert.deepEqual([...flicker.animations].filter(([key])=>key.startsWith('alternate:1:')).map(([,a])=>a.until),deadlines); + flicker.frame(source,0,1200); + assert.equal(flicker.active,false); + } finally { source.dispose(); } +}); + +test('wrapper effect flags are independent and preserve agent arguments', async () => { + const { parseOptions } = await import('../src/options.js'); + assert.deepEqual(parseOptions(['--no-rain','claude','--dangerously-skip-permissions','--no-text-flicker']), { + command:'claude', args:['--dangerously-skip-permissions'], options:{rain:false,textFlicker:false}, + }); + assert.deepEqual(parseOptions(['codex','--','--no-rain']), { + command:'codex', args:['--','--no-rain'], options:{rain:true,textFlicker:true}, + }); +}); + +for (const agent of ['claude','codex']) for (const flags of [ + ['--no-rain'], ['--no-text-flicker'], ['--no-rain','--no-text-flicker'], +]) test(`${agent}: effect options ${flags.join(' ')}`, {timeout:10000}, async () => { + const dir = await mkdtemp(join(tmpdir(),'matrix-flags-')); + await writeFile(join(dir,agent), `#!${process.execPath} +process.stdin.setRawMode(true); process.stdin.resume(); +process.stdout.write('Existing output\\r\\nWorking (esc to interrupt)\\r\\n> '); +process.stdin.on('data',b=>{ + if(b.toString().includes('hello')) {process.stdout.write('\\r\\nReceived hello');setTimeout(()=>process.exit(0),100);} +}); +`, {mode:0o755}); + const term = new xterm.Terminal({cols:80,rows:24,allowProposedApi:true}); + const child = pty.spawn(process.execPath,[resolve('src/cli.js'),agent,...flags], { + cols:80,rows:24,cwd:process.cwd(),env:{...process.env,PATH:`${dir}:${process.env.PATH}`}, + }); + let raw='',exited=false; + child.onData(data=>{raw+=data;term.write(data);}); + const exit = new Promise(resolve=>child.onExit(e=>{exited=true;resolve(e);})); + const screen=()=>screenLines(term).join('\n'); + const sleep=ms=>new Promise(r=>setTimeout(r,ms)); + async function until(check) { + const start=Date.now(); + while(!check()) {if(Date.now()-start>5000)throw new Error('Timed out: '+screen());await sleep(20);} + } + try { + if (flags.includes('--no-rain')) { + await until(()=>screen().includes('Existing output')); + child.write('\x1d'); + await sleep(150); + assert.ok(!screen().includes(agent+' working')); + // Input and paste must not be consumed just because the agent is working. + child.write('\x1b[200~hello\x1b[201~'); + } else { + await until(()=>screen().includes(agent+' working')); + raw=''; + child.write('x'); + await until(()=>screen().includes('Existing output')); + child.write('hello'); + } + assert.equal((await exit).exitCode,0); + assert.ok(raw.includes('Received hello')); + if (flags.includes('--no-text-flicker')) assert.ok(!raw.includes('\x1b[0;38;5;195m') && !raw.includes('\x1b[0;38;5;46m')); + } finally {if(!exited)child.kill();term.dispose();await rm(dir,{recursive:true,force:true});} +});