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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 |
Expand Down
14 changes: 10 additions & 4 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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; }
}
79 changes: 79 additions & 0 deletions src/flicker.js
Original file line number Diff line number Diff line change
@@ -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<previous.length && j<current.length) {
if (previous[i].text === current[j].text) {
matches.set(current[j++].row,previous[i++].row);
} else if (lengths[i+1][j] >= 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<term.rows;y++) {
const row = Math.max(0,buffer.baseY-offset)+y;
const text = buffer.getLine(row)?.translateToString(true) || '';
if (text.trim()) lines.push({row,text});
}
const matches = matchLines(this.bufferType === buffer.type ? this.lines : [],lines);
for (let y=0; y<term.rows; y++) {
const row = Math.max(0,buffer.baseY-offset)+y;
const line = buffer.getLine(row);
for (let x=0;x<term.cols;x++) {
const cell = line?.getCell(x);
if (!cell || !cell.getWidth()) continue;
const text = cell.getChars();
const key = `${buffer.type}:${row}:${x}`;
current.set(key,text);
// Keep the editable prompt, status rows and dialogs immediately legible.
const eligible = enabled && !offset && y !== buffer.cursorY && y < term.rows-2 && text.trim();
const previousKey = `${buffer.type}:${matches.get(row) ?? row}:${x}`;
let animation = this.animations.get(previousKey);
if (eligible && this.previous.get(previousKey) !== text) {
animation = {until: now + this.duration*(.15+.85*this.random()),seed:Math.floor(this.random()*glyphs.length)};
}
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 ? ' ' : ''}`;
}
}
}
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`;
return out;
}
}
15 changes: 15 additions & 0 deletions src/options.js
Original file line number Diff line number Diff line change
@@ -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 };
}
34 changes: 6 additions & 28 deletions src/return.js
Original file line number Diff line number Diff line change
@@ -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<term.rows-2; y++) {
const line = buffer.getLine(buffer.baseY+y);
for (let x=0; x<term.cols; x++) {
const cell = line?.getCell(x);
if (cell?.getWidth() === 0) continue;
const width = cell?.getWidth() || 1;
const stagger = ((x*17)%23)/23;
const edge = (progress*1.28-stagger*.24)*(term.rows-2);
if (y >= edge) {
for (let dx=0;dx<width;dx++) {
out += `\x1b[${y+1};${x+dx+1}H\x1b[0m${rain.lastGrid?.[y]?.[x+dx] || ' '}`;
}
} else if (y > 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);
}
}
33 changes: 19 additions & 14 deletions src/session.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
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';

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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -85,26 +89,27 @@ 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;
}
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);
Expand Down
Loading
Loading