ai-cli 0.0.12 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -34
- package/src/cli.test.ts +95 -0
- package/src/commands/completions.ts +296 -0
- package/src/commands/image.ts +132 -0
- package/src/commands/models.ts +117 -0
- package/src/commands/text.ts +113 -0
- package/src/commands/video.ts +109 -0
- package/src/index.ts +30 -0
- package/src/lib/color.ts +5 -0
- package/src/lib/h264-wasm.ts +164 -0
- package/src/lib/h264.test.ts +48 -0
- package/src/lib/jobs.ts +192 -0
- package/src/lib/kitty.ts +55 -0
- package/src/lib/models.test.ts +197 -0
- package/src/lib/models.ts +163 -0
- package/src/lib/mp4.test.ts +231 -0
- package/src/lib/mp4.ts +560 -0
- package/src/lib/openh264.d.mts +28 -0
- package/src/lib/openh264.mjs +423 -0
- package/src/lib/openh264.wasm +0 -0
- package/src/lib/openh264.wasm.d.ts +2 -0
- package/src/lib/output.ts +97 -0
- package/src/lib/p-map.test.ts +63 -0
- package/src/lib/p-map.ts +30 -0
- package/src/lib/parse.test.ts +114 -0
- package/src/lib/parse.ts +44 -0
- package/src/lib/png.test.ts +104 -0
- package/src/lib/png.ts +90 -0
- package/src/lib/progress.ts +214 -0
- package/src/lib/shimmer.test.ts +39 -0
- package/src/lib/shimmer.ts +42 -0
- package/src/lib/stdin.ts +31 -0
- package/README.md +0 -298
- package/dist/ai.mjs +0 -627
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
parsePositiveInt,
|
|
5
|
+
parseNonNegativeFloat,
|
|
6
|
+
parseSize,
|
|
7
|
+
parseAspectRatio,
|
|
8
|
+
parseTemperature,
|
|
9
|
+
} from "./parse.js";
|
|
10
|
+
|
|
11
|
+
describe("parsePositiveInt", () => {
|
|
12
|
+
test("parses valid positive integers", () => {
|
|
13
|
+
expect(parsePositiveInt("1", "count")).toBe(1);
|
|
14
|
+
expect(parsePositiveInt("42", "count")).toBe(42);
|
|
15
|
+
expect(parsePositiveInt("1000", "count")).toBe(1000);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("throws on zero", () => {
|
|
19
|
+
expect(() => parsePositiveInt("0", "count")).toThrow("positive integer");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("throws on negative", () => {
|
|
23
|
+
expect(() => parsePositiveInt("-1", "count")).toThrow("positive integer");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("throws on non-numeric", () => {
|
|
27
|
+
expect(() => parsePositiveInt("abc", "count")).toThrow("positive integer");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("rejects float string", () => {
|
|
31
|
+
expect(() => parsePositiveInt("1.5", "count")).toThrow("positive integer");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rejects string with trailing non-digits", () => {
|
|
35
|
+
expect(() => parsePositiveInt("10px", "count")).toThrow("positive integer");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("includes flag name in error", () => {
|
|
39
|
+
expect(() => parsePositiveInt("bad", "max-tokens")).toThrow("--max-tokens");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("parseNonNegativeFloat", () => {
|
|
44
|
+
test("parses valid non-negative floats", () => {
|
|
45
|
+
expect(parseNonNegativeFloat("0", "temperature")).toBe(0);
|
|
46
|
+
expect(parseNonNegativeFloat("1.5", "temperature")).toBe(1.5);
|
|
47
|
+
expect(parseNonNegativeFloat("2", "temperature")).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("throws on negative", () => {
|
|
51
|
+
expect(() => parseNonNegativeFloat("-0.1", "temperature")).toThrow(
|
|
52
|
+
"non-negative"
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("throws on non-numeric", () => {
|
|
57
|
+
expect(() => parseNonNegativeFloat("abc", "temperature")).toThrow(
|
|
58
|
+
"non-negative"
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("includes flag name in error", () => {
|
|
63
|
+
expect(() => parseNonNegativeFloat("bad", "temperature")).toThrow(
|
|
64
|
+
"--temperature"
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("parseSize", () => {
|
|
70
|
+
test("parses valid sizes", () => {
|
|
71
|
+
expect(parseSize("1024x1024")).toBe("1024x1024");
|
|
72
|
+
expect(parseSize("512x768")).toBe("512x768");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("rejects invalid formats", () => {
|
|
76
|
+
expect(() => parseSize("abc")).toThrow("WxH format");
|
|
77
|
+
expect(() => parseSize("1024")).toThrow("WxH format");
|
|
78
|
+
expect(() => parseSize("1024x")).toThrow("WxH format");
|
|
79
|
+
expect(() => parseSize("16:9")).toThrow("WxH format");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("parseAspectRatio", () => {
|
|
84
|
+
test("parses valid ratios", () => {
|
|
85
|
+
expect(parseAspectRatio("16:9")).toBe("16:9");
|
|
86
|
+
expect(parseAspectRatio("1:1")).toBe("1:1");
|
|
87
|
+
expect(parseAspectRatio("4:3")).toBe("4:3");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("rejects invalid formats", () => {
|
|
91
|
+
expect(() => parseAspectRatio("abc")).toThrow("W:H format");
|
|
92
|
+
expect(() => parseAspectRatio("16")).toThrow("W:H format");
|
|
93
|
+
expect(() => parseAspectRatio("16x9")).toThrow("W:H format");
|
|
94
|
+
expect(() => parseAspectRatio("16:")).toThrow("W:H format");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("parseTemperature", () => {
|
|
99
|
+
test("parses valid temperatures", () => {
|
|
100
|
+
expect(parseTemperature("0")).toBe(0);
|
|
101
|
+
expect(parseTemperature("1")).toBe(1);
|
|
102
|
+
expect(parseTemperature("1.5")).toBe(1.5);
|
|
103
|
+
expect(parseTemperature("2")).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rejects out of range", () => {
|
|
107
|
+
expect(() => parseTemperature("-0.1")).toThrow("between 0 and 2");
|
|
108
|
+
expect(() => parseTemperature("2.1")).toThrow("between 0 and 2");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("rejects non-numeric", () => {
|
|
112
|
+
expect(() => parseTemperature("abc")).toThrow("between 0 and 2");
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/lib/parse.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function parsePositiveInt(value: string, name: string): number {
|
|
2
|
+
if (!/^\d+$/.test(value)) {
|
|
3
|
+
throw new Error(`--${name} must be a positive integer, got "${value}"`);
|
|
4
|
+
}
|
|
5
|
+
const n = parseInt(value, 10);
|
|
6
|
+
if (n <= 0) {
|
|
7
|
+
throw new Error(`--${name} must be a positive integer, got "${value}"`);
|
|
8
|
+
}
|
|
9
|
+
return n;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseNonNegativeFloat(value: string, name: string): number {
|
|
13
|
+
const n = parseFloat(value);
|
|
14
|
+
if (isNaN(n) || n < 0) {
|
|
15
|
+
throw new Error(`--${name} must be a non-negative number, got "${value}"`);
|
|
16
|
+
}
|
|
17
|
+
return n;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseSize(value: string): `${number}x${number}` {
|
|
21
|
+
if (!/^\d+x\d+$/.test(value)) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`--size must be in WxH format (e.g. 1024x1024), got "${value}"`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return value as `${number}x${number}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseAspectRatio(value: string): `${number}:${number}` {
|
|
30
|
+
if (!/^\d+:\d+$/.test(value)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`--aspect-ratio must be in W:H format (e.g. 16:9), got "${value}"`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return value as `${number}:${number}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseTemperature(value: string): number {
|
|
39
|
+
const n = parseFloat(value);
|
|
40
|
+
if (isNaN(n) || n < 0 || n > 2) {
|
|
41
|
+
throw new Error(`--temperature must be between 0 and 2, got "${value}"`);
|
|
42
|
+
}
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { encodePNG } from "./png.js";
|
|
4
|
+
|
|
5
|
+
describe("encodePNG", () => {
|
|
6
|
+
test("produces valid PNG signature", () => {
|
|
7
|
+
const width = 2;
|
|
8
|
+
const height = 2;
|
|
9
|
+
const yuvSize = width * height + 2 * (width >> 1) * (height >> 1);
|
|
10
|
+
const yuv = new Uint8Array(yuvSize);
|
|
11
|
+
yuv.fill(128);
|
|
12
|
+
|
|
13
|
+
const png = encodePNG(yuv, width, height);
|
|
14
|
+
|
|
15
|
+
// PNG signature: 0x89 P N G \r \n 0x1a \n
|
|
16
|
+
expect(png[0]).toBe(137);
|
|
17
|
+
expect(png[1]).toBe(80); // P
|
|
18
|
+
expect(png[2]).toBe(78); // N
|
|
19
|
+
expect(png[3]).toBe(71); // G
|
|
20
|
+
expect(png[4]).toBe(13);
|
|
21
|
+
expect(png[5]).toBe(10);
|
|
22
|
+
expect(png[6]).toBe(26);
|
|
23
|
+
expect(png[7]).toBe(10);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("IHDR chunk has correct dimensions", () => {
|
|
27
|
+
const width = 4;
|
|
28
|
+
const height = 2;
|
|
29
|
+
const yuvSize = width * height + 2 * (width >> 1) * (height >> 1);
|
|
30
|
+
const yuv = new Uint8Array(yuvSize);
|
|
31
|
+
yuv.fill(128);
|
|
32
|
+
|
|
33
|
+
const png = encodePNG(yuv, width, height);
|
|
34
|
+
|
|
35
|
+
// IHDR starts at offset 8 (after signature)
|
|
36
|
+
// 4 bytes length + 4 bytes "IHDR" + 13 bytes data + 4 bytes CRC
|
|
37
|
+
const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
38
|
+
const ihdrLen = view.getUint32(8);
|
|
39
|
+
expect(ihdrLen).toBe(13);
|
|
40
|
+
|
|
41
|
+
// "IHDR"
|
|
42
|
+
expect(String.fromCharCode(png[12], png[13], png[14], png[15])).toBe(
|
|
43
|
+
"IHDR"
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Width and height
|
|
47
|
+
const w = view.getUint32(16);
|
|
48
|
+
const h = view.getUint32(20);
|
|
49
|
+
expect(w).toBe(4);
|
|
50
|
+
expect(h).toBe(2);
|
|
51
|
+
|
|
52
|
+
// Bit depth
|
|
53
|
+
expect(png[24]).toBe(8);
|
|
54
|
+
// Color type (RGB)
|
|
55
|
+
expect(png[25]).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("output contains IEND chunk", () => {
|
|
59
|
+
const width = 2;
|
|
60
|
+
const height = 2;
|
|
61
|
+
const yuvSize = width * height + 2 * (width >> 1) * (height >> 1);
|
|
62
|
+
const yuv = new Uint8Array(yuvSize);
|
|
63
|
+
yuv.fill(128);
|
|
64
|
+
|
|
65
|
+
const png = encodePNG(yuv, width, height);
|
|
66
|
+
|
|
67
|
+
// IEND should be the last chunk: 0-length + "IEND" + CRC = 12 bytes at the end
|
|
68
|
+
const iendType = String.fromCharCode(
|
|
69
|
+
png[png.length - 8],
|
|
70
|
+
png[png.length - 7],
|
|
71
|
+
png[png.length - 6],
|
|
72
|
+
png[png.length - 5]
|
|
73
|
+
);
|
|
74
|
+
expect(iendType).toBe("IEND");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("converts gray YUV to gray RGB", () => {
|
|
78
|
+
// Y=128, Cb=128 (neutral), Cr=128 (neutral) should produce mid-gray (~128)
|
|
79
|
+
const width = 2;
|
|
80
|
+
const height = 2;
|
|
81
|
+
const yuv = new Uint8Array(width * height + 2);
|
|
82
|
+
yuv[0] = 128;
|
|
83
|
+
yuv[1] = 128;
|
|
84
|
+
yuv[2] = 128;
|
|
85
|
+
yuv[3] = 128; // Y
|
|
86
|
+
yuv[4] = 128; // Cb
|
|
87
|
+
yuv[5] = 128; // Cr
|
|
88
|
+
|
|
89
|
+
const png = encodePNG(yuv, width, height);
|
|
90
|
+
expect(png.length).toBeGreaterThan(8);
|
|
91
|
+
// Valid PNG (we trust the encoder at this level)
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("handles 1x1 image", () => {
|
|
95
|
+
const yuv = new Uint8Array(3);
|
|
96
|
+
yuv[0] = 200; // Y
|
|
97
|
+
yuv[1] = 128; // Cb
|
|
98
|
+
yuv[2] = 128; // Cr
|
|
99
|
+
|
|
100
|
+
const png = encodePNG(yuv, 1, 1);
|
|
101
|
+
expect(png[0]).toBe(137); // PNG signature
|
|
102
|
+
expect(png.length).toBeGreaterThan(20);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/lib/png.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { deflateSync } from "zlib";
|
|
2
|
+
|
|
3
|
+
export function encodePNG(
|
|
4
|
+
yuv: Uint8Array,
|
|
5
|
+
width: number,
|
|
6
|
+
height: number
|
|
7
|
+
): Buffer {
|
|
8
|
+
const chromaW = width >> 1;
|
|
9
|
+
|
|
10
|
+
const lumaOffset = 0;
|
|
11
|
+
const cbOffset = width * height;
|
|
12
|
+
const crOffset = cbOffset + chromaW * (height >> 1);
|
|
13
|
+
|
|
14
|
+
// YUV420 -> RGB, with PNG filter byte per row
|
|
15
|
+
const rowSize = width * 3 + 1;
|
|
16
|
+
const raw = Buffer.alloc(rowSize * height);
|
|
17
|
+
|
|
18
|
+
for (let y = 0; y < height; y++) {
|
|
19
|
+
raw[y * rowSize] = 0; // filter: None
|
|
20
|
+
for (let x = 0; x < width; x++) {
|
|
21
|
+
const yVal = yuv[lumaOffset + y * width + x];
|
|
22
|
+
const cb = yuv[cbOffset + (y >> 1) * chromaW + (x >> 1)] - 128;
|
|
23
|
+
const cr = yuv[crOffset + (y >> 1) * chromaW + (x >> 1)] - 128;
|
|
24
|
+
|
|
25
|
+
const r = yVal + 1.402 * cr;
|
|
26
|
+
const g = yVal - 0.344136 * cb - 0.714136 * cr;
|
|
27
|
+
const b = yVal + 1.772 * cb;
|
|
28
|
+
|
|
29
|
+
const off = y * rowSize + 1 + x * 3;
|
|
30
|
+
raw[off] = clamp(r);
|
|
31
|
+
raw[off + 1] = clamp(g);
|
|
32
|
+
raw[off + 2] = clamp(b);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const compressed = deflateSync(raw);
|
|
37
|
+
|
|
38
|
+
// Build PNG file
|
|
39
|
+
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
40
|
+
|
|
41
|
+
const ihdr = Buffer.alloc(13);
|
|
42
|
+
ihdr.writeUInt32BE(width, 0);
|
|
43
|
+
ihdr.writeUInt32BE(height, 4);
|
|
44
|
+
ihdr[8] = 8; // bit depth
|
|
45
|
+
ihdr[9] = 2; // color type: RGB
|
|
46
|
+
ihdr[10] = 0; // compression
|
|
47
|
+
ihdr[11] = 0; // filter
|
|
48
|
+
ihdr[12] = 0; // interlace
|
|
49
|
+
|
|
50
|
+
const ihdrChunk = makeChunk("IHDR", ihdr);
|
|
51
|
+
const idatChunk = makeChunk("IDAT", compressed);
|
|
52
|
+
const iendChunk = makeChunk("IEND", Buffer.alloc(0));
|
|
53
|
+
|
|
54
|
+
return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeChunk(type: string, data: Buffer): Buffer {
|
|
58
|
+
const typeBytes = Buffer.from(type, "ascii");
|
|
59
|
+
const len = Buffer.alloc(4);
|
|
60
|
+
len.writeUInt32BE(data.length, 0);
|
|
61
|
+
|
|
62
|
+
const crcInput = Buffer.concat([typeBytes, data]);
|
|
63
|
+
const crc = Buffer.alloc(4);
|
|
64
|
+
crc.writeUInt32BE(crc32(crcInput), 0);
|
|
65
|
+
|
|
66
|
+
return Buffer.concat([len, typeBytes, data, crc]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CRC_TABLE = new Uint32Array(256);
|
|
70
|
+
{
|
|
71
|
+
for (let n = 0; n < 256; n++) {
|
|
72
|
+
let c = n;
|
|
73
|
+
for (let k = 0; k < 8; k++) {
|
|
74
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
75
|
+
}
|
|
76
|
+
CRC_TABLE[n] = c;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function crc32(buf: Buffer): number {
|
|
81
|
+
let crc = 0xffffffff;
|
|
82
|
+
for (let i = 0; i < buf.length; i++) {
|
|
83
|
+
crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
|
84
|
+
}
|
|
85
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clamp(v: number): number {
|
|
89
|
+
return v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
|
|
90
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { isColorEnabled } from "./color.js";
|
|
2
|
+
import { nextShimmerPos, SHIMMER_PADDING, shimmerText } from "./shimmer.js";
|
|
3
|
+
|
|
4
|
+
let cleanupFn: (() => void) | null = null;
|
|
5
|
+
let signalHandlersRegistered = false;
|
|
6
|
+
|
|
7
|
+
function onSignal() {
|
|
8
|
+
if (cleanupFn) cleanupFn();
|
|
9
|
+
process.stderr.write("\x1b[0m\x1b[?25h");
|
|
10
|
+
process.exit(130);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ensureSignalHandlers() {
|
|
14
|
+
if (signalHandlersRegistered) return;
|
|
15
|
+
signalHandlersRegistered = true;
|
|
16
|
+
process.on("SIGINT", onSignal);
|
|
17
|
+
process.on("SIGTERM", onSignal);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatElapsed(ms: number): string {
|
|
21
|
+
const secs = Math.floor(ms / 1000);
|
|
22
|
+
if (secs < 60) return `${secs}s`;
|
|
23
|
+
const mins = Math.floor(secs / 60);
|
|
24
|
+
const remainSecs = secs % 60;
|
|
25
|
+
return `${mins}m ${remainSecs}s`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isTTY(): boolean {
|
|
29
|
+
return !!process.stderr.isTTY;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getColumns(): number {
|
|
33
|
+
return (
|
|
34
|
+
(process.stderr as typeof process.stderr & { columns?: number }).columns ??
|
|
35
|
+
80
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Progress {
|
|
40
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
private pos = -SHIMMER_PADDING;
|
|
42
|
+
private text = "";
|
|
43
|
+
private startTime = 0;
|
|
44
|
+
private quiet: boolean;
|
|
45
|
+
|
|
46
|
+
constructor(quiet = false) {
|
|
47
|
+
this.quiet = quiet;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
start(message: string) {
|
|
51
|
+
if (this.quiet) return;
|
|
52
|
+
ensureSignalHandlers();
|
|
53
|
+
this.startTime = Date.now();
|
|
54
|
+
this.text = message;
|
|
55
|
+
this.pos = -SHIMMER_PADDING;
|
|
56
|
+
if (isTTY()) {
|
|
57
|
+
cleanupFn = () => {
|
|
58
|
+
process.stderr.write("\r\x1b[K");
|
|
59
|
+
};
|
|
60
|
+
this.render();
|
|
61
|
+
this.interval = setInterval(() => this.render(), 50);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
stop(message?: string) {
|
|
66
|
+
if (this.quiet) return;
|
|
67
|
+
if (this.interval) {
|
|
68
|
+
clearInterval(this.interval);
|
|
69
|
+
this.interval = null;
|
|
70
|
+
}
|
|
71
|
+
cleanupFn = null;
|
|
72
|
+
if (isTTY()) {
|
|
73
|
+
process.stderr.write("\r\x1b[K");
|
|
74
|
+
}
|
|
75
|
+
if (message) {
|
|
76
|
+
const elapsed = formatElapsed(Date.now() - this.startTime);
|
|
77
|
+
process.stderr.write(`${message} (${elapsed})\n`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private render() {
|
|
82
|
+
const elapsed = formatElapsed(Date.now() - this.startTime);
|
|
83
|
+
const columns = getColumns();
|
|
84
|
+
const suffix = ` (${elapsed})`;
|
|
85
|
+
const maxWidth = columns - 1 - suffix.length;
|
|
86
|
+
const display =
|
|
87
|
+
this.text.length > maxWidth ? this.text.slice(-maxWidth) : this.text;
|
|
88
|
+
const fullText = `${display}${suffix}`;
|
|
89
|
+
|
|
90
|
+
if (isColorEnabled()) {
|
|
91
|
+
process.stderr.write(`\r${shimmerText(fullText, this.pos)}\x1b[K`);
|
|
92
|
+
} else {
|
|
93
|
+
process.stderr.write(`\r${fullText}\x1b[K`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.pos = nextShimmerPos(this.pos, fullText.length);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type LineState = "queued" | "active" | "done";
|
|
101
|
+
|
|
102
|
+
interface MultiLine {
|
|
103
|
+
text: string;
|
|
104
|
+
state: LineState;
|
|
105
|
+
startTime: number;
|
|
106
|
+
pos: number;
|
|
107
|
+
finalText: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class MultiProgress {
|
|
111
|
+
private lines: MultiLine[] = [];
|
|
112
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
113
|
+
private quiet: boolean;
|
|
114
|
+
private renderedCount = 0;
|
|
115
|
+
|
|
116
|
+
constructor(quiet = false) {
|
|
117
|
+
this.quiet = quiet;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
addLine(text: string): number {
|
|
121
|
+
ensureSignalHandlers();
|
|
122
|
+
const idx = this.lines.length;
|
|
123
|
+
this.lines.push({
|
|
124
|
+
text,
|
|
125
|
+
state: "queued",
|
|
126
|
+
startTime: 0,
|
|
127
|
+
pos: -SHIMMER_PADDING,
|
|
128
|
+
finalText: "",
|
|
129
|
+
});
|
|
130
|
+
if (!this.quiet && isTTY() && !this.interval) {
|
|
131
|
+
cleanupFn = () => this.eraseLines();
|
|
132
|
+
this.interval = setInterval(() => this.render(), 50);
|
|
133
|
+
}
|
|
134
|
+
if (isTTY()) this.render();
|
|
135
|
+
return idx;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
startLine(idx: number) {
|
|
139
|
+
if (idx < 0 || idx >= this.lines.length) return;
|
|
140
|
+
const line = this.lines[idx];
|
|
141
|
+
if (line.state !== "queued") return;
|
|
142
|
+
line.state = "active";
|
|
143
|
+
line.startTime = Date.now();
|
|
144
|
+
line.pos = -SHIMMER_PADDING;
|
|
145
|
+
if (isTTY()) this.render();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
completeLine(idx: number, finalText: string) {
|
|
149
|
+
if (idx < 0 || idx >= this.lines.length) return;
|
|
150
|
+
const line = this.lines[idx];
|
|
151
|
+
line.state = "done";
|
|
152
|
+
line.finalText = finalText;
|
|
153
|
+
|
|
154
|
+
if (this.lines.every((l) => l.state === "done")) {
|
|
155
|
+
this.stopAll();
|
|
156
|
+
} else if (isTTY()) {
|
|
157
|
+
this.render();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private stopAll() {
|
|
162
|
+
if (this.interval) {
|
|
163
|
+
clearInterval(this.interval);
|
|
164
|
+
this.interval = null;
|
|
165
|
+
}
|
|
166
|
+
cleanupFn = null;
|
|
167
|
+
if (isTTY()) this.eraseLines();
|
|
168
|
+
if (!this.quiet) {
|
|
169
|
+
for (const line of this.lines) {
|
|
170
|
+
process.stderr.write(`${line.finalText}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
this.renderedCount = 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private render() {
|
|
177
|
+
if (this.quiet) return;
|
|
178
|
+
this.eraseLines();
|
|
179
|
+
|
|
180
|
+
const columns = getColumns();
|
|
181
|
+
const dim = isColorEnabled() ? "\x1b[2m\x1b[90m" : "";
|
|
182
|
+
const reset = isColorEnabled() ? "\x1b[0m" : "";
|
|
183
|
+
|
|
184
|
+
for (const line of this.lines) {
|
|
185
|
+
if (line.state === "done") {
|
|
186
|
+
process.stderr.write(`${line.finalText}\x1b[K\n`);
|
|
187
|
+
} else if (line.state === "queued") {
|
|
188
|
+
process.stderr.write(`${dim}${line.text} (queued)${reset}\x1b[K\n`);
|
|
189
|
+
} else {
|
|
190
|
+
const elapsed = formatElapsed(Date.now() - line.startTime);
|
|
191
|
+
const suffix = ` (${elapsed})`;
|
|
192
|
+
const maxWidth = columns - 1 - suffix.length;
|
|
193
|
+
const display =
|
|
194
|
+
line.text.length > maxWidth ? line.text.slice(-maxWidth) : line.text;
|
|
195
|
+
const fullText = `${display}${suffix}`;
|
|
196
|
+
|
|
197
|
+
if (isColorEnabled()) {
|
|
198
|
+
process.stderr.write(`${shimmerText(fullText, line.pos)}\x1b[K\n`);
|
|
199
|
+
} else {
|
|
200
|
+
process.stderr.write(`${fullText}\x1b[K\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
line.pos = nextShimmerPos(line.pos, fullText.length);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.renderedCount = this.lines.length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private eraseLines() {
|
|
211
|
+
if (this.renderedCount === 0) return;
|
|
212
|
+
process.stderr.write(`\x1b[${this.renderedCount}A\r`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { shimmerText, nextShimmerPos, SHIMMER_PADDING } from "./shimmer.js";
|
|
4
|
+
|
|
5
|
+
describe("shimmerText", () => {
|
|
6
|
+
test("returns empty string unchanged", () => {
|
|
7
|
+
expect(shimmerText("", 0)).toBe("");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("returns plain text when NO_COLOR is set", () => {
|
|
11
|
+
const prev = process.env.NO_COLOR;
|
|
12
|
+
process.env.NO_COLOR = "1";
|
|
13
|
+
try {
|
|
14
|
+
expect(shimmerText("hello", 2)).toBe("hello");
|
|
15
|
+
} finally {
|
|
16
|
+
if (prev === undefined) delete process.env.NO_COLOR;
|
|
17
|
+
else process.env.NO_COLOR = prev;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("nextShimmerPos", () => {
|
|
23
|
+
test("increments position by 1", () => {
|
|
24
|
+
expect(nextShimmerPos(0, 20)).toBe(1);
|
|
25
|
+
expect(nextShimmerPos(5, 20)).toBe(6);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("wraps around after passing text length + padding + gap", () => {
|
|
29
|
+
const textLen = 10;
|
|
30
|
+
const wrapPoint = textLen + SHIMMER_PADDING + 6;
|
|
31
|
+
expect(nextShimmerPos(wrapPoint, textLen)).toBe(-SHIMMER_PADDING);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("does not wrap before threshold", () => {
|
|
35
|
+
const textLen = 10;
|
|
36
|
+
const beforeWrap = textLen + SHIMMER_PADDING + 5;
|
|
37
|
+
expect(nextShimmerPos(beforeWrap, textLen)).toBe(beforeWrap + 1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isColorEnabled } from "./color.js";
|
|
2
|
+
|
|
3
|
+
const SHIMMER_RADIUS = 8;
|
|
4
|
+
const BASE = 243;
|
|
5
|
+
const PEAK = 255;
|
|
6
|
+
const GAP_FRAMES = 6;
|
|
7
|
+
|
|
8
|
+
function charShade(charIdx: number, shimmerPos: number): number {
|
|
9
|
+
const d = Math.abs(charIdx - shimmerPos);
|
|
10
|
+
if (d >= SHIMMER_RADIUS) return BASE;
|
|
11
|
+
const t = (1 + Math.cos((d / SHIMMER_RADIUS) * Math.PI)) / 2;
|
|
12
|
+
return Math.round(BASE + (PEAK - BASE) * t);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function shimmerText(text: string, pos: number): string {
|
|
16
|
+
if (!isColorEnabled() || text.length === 0) return text;
|
|
17
|
+
|
|
18
|
+
let result = "";
|
|
19
|
+
let prevShade = -1;
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < text.length; i++) {
|
|
22
|
+
const shade = charShade(i, pos);
|
|
23
|
+
if (shade !== prevShade) {
|
|
24
|
+
result += `\x1b[38;5;${shade}m`;
|
|
25
|
+
prevShade = shade;
|
|
26
|
+
}
|
|
27
|
+
result += text[i];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
result += "\x1b[0m";
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SHIMMER_PADDING = SHIMMER_RADIUS;
|
|
35
|
+
|
|
36
|
+
export function nextShimmerPos(pos: number, textLength: number): number {
|
|
37
|
+
const next = pos + 1;
|
|
38
|
+
if (next > textLength + SHIMMER_PADDING + GAP_FRAMES) {
|
|
39
|
+
return -SHIMMER_PADDING;
|
|
40
|
+
}
|
|
41
|
+
return next;
|
|
42
|
+
}
|
package/src/lib/stdin.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export async function readStdin(): Promise<Buffer | null> {
|
|
2
|
+
if (process.stdin.isTTY) return null;
|
|
3
|
+
|
|
4
|
+
const first = await Promise.race([
|
|
5
|
+
new Promise<Buffer | null>((resolve) => {
|
|
6
|
+
process.stdin.once("data", (chunk) => resolve(Buffer.from(chunk)));
|
|
7
|
+
process.stdin.once("end", () => resolve(null));
|
|
8
|
+
process.stdin.once("error", () => resolve(null));
|
|
9
|
+
}),
|
|
10
|
+
new Promise<"timeout">((resolve) =>
|
|
11
|
+
setTimeout(() => resolve("timeout"), 1000)
|
|
12
|
+
),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
if (first === "timeout" || first === null) {
|
|
16
|
+
process.stdin.destroy();
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const chunks: Buffer[] = [first];
|
|
21
|
+
for await (const chunk of process.stdin) {
|
|
22
|
+
chunks.push(Buffer.from(chunk));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const buf = Buffer.concat(chunks);
|
|
26
|
+
return buf.length > 0 ? buf : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stdinAsText(buf: Buffer): string {
|
|
30
|
+
return buf.toString("utf-8");
|
|
31
|
+
}
|