diff --git a/package.json b/package.json index 1a89385..0fd080e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@bomb.sh/args": "^0.3.1", + "@bomb.sh/tty": "^0.8.0", "@clack/prompts": "^0.11.0", "zod": "^4.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42b5b4c..0938586 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@bomb.sh/args': specifier: ^0.3.1 version: 0.3.1 + '@bomb.sh/tty': + specifier: ^0.8.0 + version: 0.8.0 '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 @@ -96,6 +99,10 @@ packages: '@bomb.sh/args@0.3.1': resolution: {integrity: sha512-CwxKrfgcorUPP6KfYD59aRdBYWBTsfsxT+GmoLVnKo5Tmyoqbpo0UNcjngRMyU+6tiPbd18RuIYxhgAn44wU/Q==} + '@bomb.sh/tty@0.8.0': + resolution: {integrity: sha512-tEE0mtqnEp2uJt4kQS5AjdghCD5KN4BobqHOlfhaiNyrigxFBKk61XkDy//4D7V/AejrTvubh44B4NV35lsnxw==} + engines: {node: '>= 22'} + '@clack/core@0.5.0': resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} @@ -712,6 +719,8 @@ snapshots: '@bomb.sh/args@0.3.1': {} + '@bomb.sh/tty@0.8.0': {} + '@clack/core@0.5.0': dependencies: picocolors: 1.1.1 diff --git a/src/banner.ts b/src/banner.ts new file mode 100644 index 0000000..909e344 --- /dev/null +++ b/src/banner.ts @@ -0,0 +1,103 @@ +import { + close, + createTerm, + fixed, + grow, + open, + rgba, + text, + type Op, +} from "@bomb.sh/tty"; + +const ART = [ + " __ _ _", + " / _(_) __| |", + "| |_| |/ _` |", + "| _| | (_| |", + "|_| |_|\\__,_|", +]; +const TAGLINE = "a fid works knots. this one works tangled’s."; +const HEIGHT = ART.length + 2; // art + blank + tagline + +const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; +export const staticBanner = `\n${ART.join("\n")}\n\n${dim(TAGLINE)}\n`; + +const GREEN = rgba(74, 222, 128); +const WHITE = rgba(255, 255, 255); +const GRAY = rgba(90, 95, 105); +const TAG = rgba(140, 145, 155); + +// where is the cursor? (DSR roundtrip; null = terminal didn't answer) +function cursorRow(): Promise { + return new Promise((resolve) => { + const stdin = process.stdin; + const done = (row: number | null) => { + clearTimeout(timer); + stdin.off("data", onData); + stdin.setRawMode(false); + stdin.pause(); + resolve(row); + }; + const timer = setTimeout(() => done(null), 250); + const onData = (buf: Buffer) => { + const m = /\x1b\[(\d+);\d+R/.exec(buf.toString("utf8")); + if (m) done(Number(m[1])); + }; + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", onData); + process.stdout.write("\x1b[6n"); + }); +} + +export async function animateBanner(): Promise { + if (!process.stdout.isTTY || !process.stdin.isTTY || process.env.CI) { + console.log(staticBanner); + return; + } + + // reserve our rows first so the math survives scrolling at screen bottom + process.stdout.write("\n".repeat(HEIGHT)); + const after = await cursorRow(); + if (after === null) return; // banner sacrificed; don't garble the terminal + const top = Math.max(1, after - HEIGHT); + + const width = Math.min(process.stdout.columns || 80, 60); + const term = await createTerm({ width, height: HEIGHT }); + const artWidth = Math.max(...ART.map((l) => l.length)); + + process.stdout.write("\x1b[?25l"); + const frames = 24; + for (let f = 0; f <= frames; f++) { + const t = f / frames; + const head = Math.floor(t * (artWidth + 3)) - 1; // sweep column, slight overshoot + const ops: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ]; + ART.forEach((line, i) => { + ops.push(open(`l${i}`, { layout: { direction: "ltr" } })); + const a = Math.min(Math.max(head, 0), line.length); + const b = Math.min(a + 2, line.length); + if (a > 0) ops.push(text(line.slice(0, a), { color: GREEN })); + if (b > a) ops.push(text(line.slice(a, b), { color: WHITE })); + if (b < line.length) ops.push(text(line.slice(b), { color: GRAY })); + ops.push(close()); + }); + ops.push(open("gap", { layout: { height: fixed(1) } }), close()); + const typed = TAGLINE.slice(0, Math.floor(t * TAGLINE.length)); + ops.push(open("tag", {})); + if (typed) ops.push(text(typed, { color: TAG })); + ops.push(close(), close()); + + const { output } = term.render(ops, { mode: "line", row: top }); + // line mode emits plain rows with no cursor addressing — repark at the + // region top each frame or every frame stacks below the previous one + process.stdout.write(`\x1b[${top};1H`); + process.stdout.write(output); + if (f < frames) await new Promise((r) => setTimeout(r, 26)); + } + // park the cursor below the banner and hand the terminal back + process.stdout.write(`\x1b[${Math.min(top + HEIGHT, after)};1H\x1b[?25h`); +} diff --git a/src/cli.ts b/src/cli.ts index 50ac398..84ee90b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,24 +1,13 @@ #!/usr/bin/env node import { parse } from '@bomb.sh/args' +import { animateBanner, staticBanner } from './banner.js' const args = parse(process.argv.slice(2), { boolean: ['json', 'all', 'fresh', 'help'], alias: { h: 'help' }, }) -const dim = (s: string) => `\x1b[2m${s}\x1b[0m` - -const banner = ` - __ _ _ - / _(_) __| | -| |_| |/ _\` | -| _| | (_| | -|_| |_|\\__,_| - -${dim('a fid works knots. this one works tangled’s.')} -` - -const usage = `${banner} +const usage = `${staticBanner} usage: fid list every knot on the network --json machine-readable output @@ -32,7 +21,7 @@ if (args.help || !command) { process.exit(command ? 0 : 1) } -if (!args.json) console.log(banner) // keep --json pipe-clean +if (!args.json) await animateBanner() // keep --json pipe-clean switch (command) { case 'list':