用 Node.js 复刻 Codex Astra 的终端星光
Codex 选中 gpt-6-astra 后,输入区域会出现一些淡淡的光点。它们停在原地,各自亮起,又慢慢隐进背景。

这类动画容易写得太吵:光点不停换位置,整片区域一起闪,或者亮到盖过文字。Astra 的处理很克制。读完实现,会发现这种观感和几个很具体的选择有关。
本文对照 Codex rust-v0.154.0 的 sparkle.rs,用 Node.js 写一个独立演示。保留原版的坐标哈希和明暗公式,使用固定的深色配色,不需要安装第三方依赖。
先看星星本身。原版使用的是八个 Unicode 盲文字符:
text
⠁ ⠂ ⠄ ⠈ ⠐ ⠠ ⡀ ⢀
每个字符只有一个点,点在字符格里的位置各不相同。同样占一个格子,它们比 * 小得多,混在一起也不容易看出整齐的网格。
接着要决定在哪里画。
如果每一帧都调用 Math.random() 重新挑位置,上一帧还亮着的地方,下一帧可能已经空了。整个区域会像噪声一样跳动。这里让坐标决定结果:把 (x, y) 传入固定的哈希函数,同一个位置始终得到同一个整数。
这个整数决定该格子有没有星星,也决定用哪个字符、多久闪一次,以及从周期的哪个位置开始。代码里的 h % 5n === 0n 会选出约五分之一的格子作为候选;候选星星多数时候很暗,所以实际看到的会更少。
哈希沿用了原版的 64 位整数运算。JavaScript 的普通位运算会把数字压到 32 位,这里用 BigInt.asUintN(64, ...) 处理溢出。计算只在初始化和窗口缩放时进行。
星星的位置定下来以后,每一帧只需重新算明暗。
先把经过的时间换算成周期内的进度:phase = (time / period + offset) % 1。period 控制多久闪一次,offset 错开每颗星的起点。然后计算混色比例:
text
alpha = sin(π × phase)¹² × 0.55
进度从 0 走到 1,正弦值会先升到 1,再降回 0。十二次方把亮起来的时间压短了。比如周期走到四分之一时,正弦值约为 0.707,取十二次方再乘 0.55,只剩约 0.0086。它会在接近周期中间时才明显亮起来。
最后的颜色按 RGB 通道计算:背景色 × (1 - alpha) + 前景色 × alpha。这是原版 blend 函数使用的混色方式。光点暗下去时会接近背景的颜色。alpha 小于 0.04 就不画。
候选格子的比例、4~7 秒的周期、十二次方曲线和 0.04 阈值都来自 原版绘制逻辑。下面把它们放进一个可以运行的脚本。
保存为 astra.mjs:
js
import assert from 'node:assert/strict';
import { writeSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
const CSI = '\x1b[';
const DOTS = ['⠁', '⠂', '⠄', '⠈', '⠐', '⠠', '⡀', '⢀'];
const BG = [18, 20, 28];
const FG = [224, 228, 240];
const write = (text) => writeSync(1, text);
function hash(x, y) {
let n = BigInt(y) * 65537n + BigInt(x);
for (let i = 0; i < 2; i++) {
n = BigInt.asUintN(64, (n ^ (n >> 16n)) * 0x45d9f3bn);
}
return n ^ (n >> 16n);
}
function brightness(time, period, offset) {
const phase = (time / period + offset) % 1;
return Math.sin(Math.PI * phase) ** 12 * 0.55;
}
function main() {
const out = process.stdout;
if (!out.isTTY || out.getColorDepth() < 24) {
console.error('请在支持 24 位真彩色的终端中运行。');
process.exitCode = 1;
return;
}
let width = 0;
let height = 0;
let stars = [];
const started = performance.now();
const background = `${CSI}48;2;${BG.join(';')}m`;
function render() {
const time = (performance.now() - started) / 1000;
const cells = Array.from({ length: height }, () => Array(width).fill(' '));
for (const star of stars) {
const alpha = brightness(time, star.period, star.offset);
if (alpha < 0.04) continue;
const rgb = BG.map((bg, i) => Math.floor(bg * (1 - alpha) + FG[i] * alpha));
cells[star.y][star.x] = `${CSI}38;2;${rgb.join(';')}m${star.dot}`;
}
// ponytail: 每帧重画至多 96×12 格;大型 TUI 使用已有的差分渲染器。
let frame = background;
for (let y = 0; y < height; y++) {
frame += `${CSI}${y + 3};2H${cells[y].join('')}`;
}
write(frame + `${CSI}0m`);
}
function resize() {
const columns = out.columns || 80;
const rows = out.rows || 24;
width = Math.max(0, Math.min(96, columns - 2));
height = Math.max(0, Math.min(12, rows - 4));
stars = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const h = hash(x, y);
if (h % 5n !== 0n) continue;
stars.push({
x, y,
dot: DOTS[Number(h / 161n % 8n)],
period: 4 + Number(h % 31n) / 10,
offset: Number(h % 997n) / 997,
});
}
}
const title = 'Astra sparkle demo | Ctrl+C to quit';
write(`${background}${CSI}2J${CSI}H${CSI}38;2;${FG.join(';')}m`
+ title.slice(0, Math.max(0, columns - 1)));
render();
}
process.on('exit', () => write(`${CSI}0m${CSI}?25h${CSI}?1049l`));
process.on('SIGINT', () => process.exit(130));
process.on('SIGTERM', () => process.exit(143));
process.on('SIGHUP', () => process.exit(129));
write(`${CSI}?1049h${CSI}?25l`);
out.on('resize', resize);
resize();
setInterval(render, 150);
}
if (process.argv.includes('--check')) {
assert.equal(hash(37, 11), 12395833206921308040n);
assert.equal(brightness(0, 4, 0), 0);
assert.equal(brightness(2, 4, 0), 0.55);
assert.ok(brightness(1, 4, 0) < 0.04);
assert.ok(Math.abs(brightness(0.7, 4, 0.13) - brightness(4.7, 4, 0.13)) < 1e-12);
assert.notEqual(brightness(0.7, 4, 0), brightness(0.7, 4, 0.5));
console.log('check passed');
} else {
main();
}
运行:
bash
node astra.mjs
按 Ctrl+C 退出。脚本会恢复光标并切回原来的屏幕。只想检查哈希和明暗计算,可以运行:
bash
node astra.mjs --check

这个演示需要支持 24 位真彩色和盲文字符的终端。代码用 isTTY 排除文件重定向,再用 getColorDepth() 判断颜色能力;后者依赖运行环境信息,检测结果可能有误差。Node.js TTY 文档说明了这些限制。如果光点显示成方框,检查终端字体是否包含这些字符。
代码里以 \x1b[ 开头的字符串是终端控制序列。38;2;R;G;Bm 设置文字颜色,48;2;R;G;Bm 设置背景色,行;列H 移动光标,坐标从 1 开始。演示还用了备用屏幕和隐藏光标的控制序列,具体定义可查 XTerm Control Sequences。
这里有一个容易漏掉的地方:星星不再显示时,要把上一帧留下的字符擦掉。
示例每帧先创建一块全是空格的区域,再填入当前可见的星星,最后一次写出。暗下去的位置自然会被空格覆盖。每帧最多重画 96×12 个格子;清屏只发生在初始化和窗口缩放时。
刷新间隔设为 150 毫秒,与原版请求下一帧的间隔一致。动画使用实际经过的时间计算进度,因此偶尔少画一帧,也不会把闪烁周期拖长。想让渐变更细腻,可以把间隔改成 50 毫秒,输出量也会随之增加。
如果要把它接进已有的输入框,先让输入框完成正常绘制,再给空白格子补上星星。原版还会跳过光标、选中样式以及宽字符占据的后续格子,弹出菜单时暂停这层装饰。官方测试覆盖了这些行为。中文和 emoji 可能占用多个终端列,接入时应使用现有 TUI 的宽度计算与屏幕缓冲区,别用字符串的 length 判断哪里有空位。
至于风格调整,先动这几个参数就够了:
| 参数 | 当前取值 | 调整后的变化 |
|---|---|---|
| 候选格子 | h % 5n === 0n |
改成 % 8n,星星更稀疏 |
| 周期 | 4~7 秒 | 增大后,每颗星闪得更慢 |
| 指数 | 12 |
改成 6,每次亮起来会持续更久 |
| 最大混色比例 | 0.55 |
增大后,星星更接近前景色 |
| 刷新间隔 | 150 毫秒 | 改成 50 毫秒,明暗变化的采样更密 |
如果是给输入框加背景,我会先保留这组偏淡的参数。输入几行字,打开补全菜单,再选中一段文本。星星有没有妨碍这些操作,比空白画面里闪得好不好看更值得检查。