diff --git a/README.md b/README.md index 1549e23..30faa5b 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 @@ -23,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 new file mode 100644 index 0000000..87156fc --- /dev/null +++ b/src/flicker.js @@ -0,0 +1,79 @@ +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(); 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= 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..2e89985 100644 --- a/src/session.js +++ b/src/session.js @@ -1,4 +1,5 @@ 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'; @@ -6,12 +7,14 @@ import { detectState, ViewState } from './state.js'; import { Rain } from './rain.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 }); 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; @@ -28,16 +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) { - 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 (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) : renderScreen(term, offset)); + 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; } @@ -46,7 +50,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,8 +72,8 @@ 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(); } - discardPaste = view.rain || offset > 0 || Boolean(autoReturn); + if (returning || flicker.active) { flicker.settle(); returning = undefined; dirty = true; paint(); } + discardPaste = (rainEnabled && view.rain) || offset > 0 || Boolean(autoReturn); if (discardPaste) { view.reveal(); offset = 0; dirty = true; paint(); } } if (!discardPaste) child?.write(event.data); @@ -85,18 +89,19 @@ 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 === '\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; @@ -104,7 +109,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..d908fa2 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,107 @@ 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(); } +}); + +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});} +});