@trawlme/cli 1.18.6 → 1.19.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/dist/commands/scraps.d.ts +41 -1
- package/dist/commands/scraps.js +63 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +24 -0
- package/dist/lib/pinch.d.ts +32 -0
- package/dist/lib/pinch.js +147 -0
- package/package.json +1 -1
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
export declare const scraps: Command;
|
|
3
3
|
/** The top-of-history snapshot pollRunProgress needs to identify which run
|
|
4
|
-
* it's watching — see captureBeforeRunState.
|
|
4
|
+
* it's watching — see captureBeforeRunState.
|
|
5
|
+
*
|
|
6
|
+
* #97 — `captured` discriminates WHY `id` is undefined: `true` means the GET
|
|
7
|
+
* succeeded and the scrap genuinely has no history yet (an honest "never
|
|
8
|
+
* run" signal pollRunProgress can trust immediately); `false` means the GET
|
|
9
|
+
* itself threw, so `id`/`alreadyInFlight` carry NO information at all — the
|
|
10
|
+
* scrap could easily have prior (possibly terminal) history that this
|
|
11
|
+
* lookup simply never saw. Before this field existed, both cases produced
|
|
12
|
+
* the identical `{id: undefined, alreadyInFlight: false}` shape, so
|
|
13
|
+
* pollRunProgress could not tell them apart (see its own #97 comment).
|
|
14
|
+
*/
|
|
5
15
|
interface BeforeRunState {
|
|
6
16
|
id?: string;
|
|
7
17
|
alreadyInFlight: boolean;
|
|
18
|
+
captured: boolean;
|
|
8
19
|
}
|
|
9
20
|
/**
|
|
10
21
|
* #91 P1 — replaces "await the run to completion, THEN open the activities
|
|
@@ -38,6 +49,35 @@ interface BeforeRunState {
|
|
|
38
49
|
* (the dedup case) — a same-id row that was already TERMINAL at capture is
|
|
39
50
|
* neither, and must not be latched onto as "done" (it's just the previous
|
|
40
51
|
* run, still sitting there until a genuinely new run supersedes it).
|
|
52
|
+
*
|
|
53
|
+
* #97 — capture-failed fallback. The dedup-race fix above assumes `before`
|
|
54
|
+
* is trustworthy. When `captureBeforeRunState`'s own GET threw,
|
|
55
|
+
* `before.id` is `undefined` — and that is INDISTINGUISHABLE from "the
|
|
56
|
+
* scrap has genuinely never run" (also `id: undefined`), which is exactly
|
|
57
|
+
* the case `isNewRun` above is designed to match on the very first row that
|
|
58
|
+
* ever appears. So on the very first poll, ANY pre-existing history row —
|
|
59
|
+
* even the STALE PREVIOUS run, already terminal — satisfied
|
|
60
|
+
* `last._id !== undefined` and got reported as "the run we just launched"
|
|
61
|
+
* finishing, when it was really just whatever ran before.
|
|
62
|
+
*
|
|
63
|
+
* Fix: `before.captured === false` defers trusting a baseline at all.
|
|
64
|
+
* Instead of comparing against the (unknown) `beforeId` from the start, the
|
|
65
|
+
* FIRST successful poll read is treated as the deferred capture itself —
|
|
66
|
+
* exactly what `captureBeforeRunState` would have returned had its GET
|
|
67
|
+
* succeeded — and only READS from that point on are compared against it,
|
|
68
|
+
* via the exact same `isNewRun` / `isDedupOntoInFlight` logic above. A
|
|
69
|
+
* pre-existing terminal row observed on that first read becomes `beforeId`
|
|
70
|
+
* (not a match for itself), so it correctly falls into "still the stale
|
|
71
|
+
* previous run" below and the poll keeps waiting; a row still in flight
|
|
72
|
+
* becomes the `alreadyInFlight` baseline, exactly like a successful capture
|
|
73
|
+
* would have recorded. Either way this costs at most one extra poll
|
|
74
|
+
* interval, bounded by the same deadline as everything else. The one
|
|
75
|
+
* remaining edge case — capture failed AND the scrap never ran before AND
|
|
76
|
+
* the triggered run already finished by the very first poll — is genuinely
|
|
77
|
+
* undecidable from "stale pre-existing row" with no more information than
|
|
78
|
+
* this function has, so it resolves to the same honest timeout rather than
|
|
79
|
+
* risk reporting a possibly-wrong outcome (never a lie, at worst a timeout
|
|
80
|
+
* telling the caller to check `doctor`).
|
|
41
81
|
*/
|
|
42
82
|
export declare function pollRunProgress(id: string, before: BeforeRunState | undefined, opts?: {
|
|
43
83
|
intervalMs?: number;
|
package/dist/commands/scraps.js
CHANGED
|
@@ -7,6 +7,7 @@ import { promptPassword } from '../lib/prompt.js';
|
|
|
7
7
|
import { validateObjectId } from '../lib/validate.js';
|
|
8
8
|
import { classifyError, reportError, UsageError } from '../lib/errors.js';
|
|
9
9
|
import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
|
|
10
|
+
import { renderPinch, pinchEnabled } from '../lib/pinch.js';
|
|
10
11
|
/**
|
|
11
12
|
* Print a usage/validation error consistently: human text to stderr, or a
|
|
12
13
|
* machine envelope on stdout under --json (never both — reportError is the
|
|
@@ -96,17 +97,21 @@ async function watchActivities(id) {
|
|
|
96
97
|
* recognize that dedup case too, instead of waiting forever for an `_id`
|
|
97
98
|
* that will never arrive (#93 item 1).
|
|
98
99
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
100
|
+
* #97 — a failed lookup used to fall back to `{alreadyInFlight:false}` with
|
|
101
|
+
* no `id`, which LOOKED identical to "scrap has never run" — but is not:
|
|
102
|
+
* the scrap may well have prior (possibly terminal) history this GET simply
|
|
103
|
+
* never observed. `captured:false` flags that distinction explicitly so
|
|
104
|
+
* pollRunProgress can defer trusting a baseline instead of risking a stale
|
|
105
|
+
* pre-existing row being reported as the run just launched.
|
|
101
106
|
*/
|
|
102
107
|
async function captureBeforeRunState(id) {
|
|
103
108
|
try {
|
|
104
109
|
const scrap = await api.get(`/api/scraps/${id}`);
|
|
105
110
|
const top = scrap.history?.[0];
|
|
106
|
-
return { id: top?._id, alreadyInFlight: top?.status === null };
|
|
111
|
+
return { id: top?._id, alreadyInFlight: top?.status === null, captured: true };
|
|
107
112
|
}
|
|
108
113
|
catch {
|
|
109
|
-
return { id: undefined, alreadyInFlight: false };
|
|
114
|
+
return { id: undefined, alreadyInFlight: false, captured: false };
|
|
110
115
|
}
|
|
111
116
|
}
|
|
112
117
|
function sleep(ms) {
|
|
@@ -148,12 +153,46 @@ const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
|
|
|
148
153
|
* (the dedup case) — a same-id row that was already TERMINAL at capture is
|
|
149
154
|
* neither, and must not be latched onto as "done" (it's just the previous
|
|
150
155
|
* run, still sitting there until a genuinely new run supersedes it).
|
|
156
|
+
*
|
|
157
|
+
* #97 — capture-failed fallback. The dedup-race fix above assumes `before`
|
|
158
|
+
* is trustworthy. When `captureBeforeRunState`'s own GET threw,
|
|
159
|
+
* `before.id` is `undefined` — and that is INDISTINGUISHABLE from "the
|
|
160
|
+
* scrap has genuinely never run" (also `id: undefined`), which is exactly
|
|
161
|
+
* the case `isNewRun` above is designed to match on the very first row that
|
|
162
|
+
* ever appears. So on the very first poll, ANY pre-existing history row —
|
|
163
|
+
* even the STALE PREVIOUS run, already terminal — satisfied
|
|
164
|
+
* `last._id !== undefined` and got reported as "the run we just launched"
|
|
165
|
+
* finishing, when it was really just whatever ran before.
|
|
166
|
+
*
|
|
167
|
+
* Fix: `before.captured === false` defers trusting a baseline at all.
|
|
168
|
+
* Instead of comparing against the (unknown) `beforeId` from the start, the
|
|
169
|
+
* FIRST successful poll read is treated as the deferred capture itself —
|
|
170
|
+
* exactly what `captureBeforeRunState` would have returned had its GET
|
|
171
|
+
* succeeded — and only READS from that point on are compared against it,
|
|
172
|
+
* via the exact same `isNewRun` / `isDedupOntoInFlight` logic above. A
|
|
173
|
+
* pre-existing terminal row observed on that first read becomes `beforeId`
|
|
174
|
+
* (not a match for itself), so it correctly falls into "still the stale
|
|
175
|
+
* previous run" below and the poll keeps waiting; a row still in flight
|
|
176
|
+
* becomes the `alreadyInFlight` baseline, exactly like a successful capture
|
|
177
|
+
* would have recorded. Either way this costs at most one extra poll
|
|
178
|
+
* interval, bounded by the same deadline as everything else. The one
|
|
179
|
+
* remaining edge case — capture failed AND the scrap never ran before AND
|
|
180
|
+
* the triggered run already finished by the very first poll — is genuinely
|
|
181
|
+
* undecidable from "stale pre-existing row" with no more information than
|
|
182
|
+
* this function has, so it resolves to the same honest timeout rather than
|
|
183
|
+
* risk reporting a possibly-wrong outcome (never a lie, at worst a timeout
|
|
184
|
+
* telling the caller to check `doctor`).
|
|
151
185
|
*/
|
|
152
186
|
export async function pollRunProgress(id, before, opts = {}) {
|
|
153
187
|
const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
154
188
|
const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
|
|
155
|
-
|
|
156
|
-
|
|
189
|
+
let beforeId = before?.id;
|
|
190
|
+
let beforeAlreadyInFlight = before?.alreadyInFlight ?? false;
|
|
191
|
+
// #97 — only an EXPLICIT captured:false (capture's GET actually threw)
|
|
192
|
+
// defers the baseline. `before` itself being undefined (callers that skip
|
|
193
|
+
// capture entirely) or `captured` being true/absent both mean "trust
|
|
194
|
+
// beforeId as given", preserving every existing call site's behavior.
|
|
195
|
+
let baselineEstablished = before?.captured ?? true;
|
|
157
196
|
console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
|
|
158
197
|
const deadline = Date.now() + timeoutMs;
|
|
159
198
|
const seen = new Set();
|
|
@@ -172,6 +211,16 @@ export async function pollRunProgress(id, before, opts = {}) {
|
|
|
172
211
|
const last = scrap.history?.[0];
|
|
173
212
|
if (!last?._id)
|
|
174
213
|
continue; // no history row recorded yet
|
|
214
|
+
if (!baselineEstablished) {
|
|
215
|
+
// #97 — deferred capture: whatever we see on this first successful
|
|
216
|
+
// read becomes the reference point, BEFORE the isNewRun check below
|
|
217
|
+
// ever runs against it. This must happen before that check, not
|
|
218
|
+
// after, so a pre-existing terminal row is recognized as stale on
|
|
219
|
+
// this very same iteration rather than one poll late.
|
|
220
|
+
beforeId = last._id;
|
|
221
|
+
beforeAlreadyInFlight = last.status === null;
|
|
222
|
+
baselineEstablished = true;
|
|
223
|
+
}
|
|
175
224
|
const isNewRun = last._id !== beforeId;
|
|
176
225
|
const isDedupOntoInFlight = last._id === beforeId && beforeAlreadyInFlight;
|
|
177
226
|
if (!isNewRun && !isDedupOntoInFlight)
|
|
@@ -194,6 +243,14 @@ export async function pollRunProgress(id, before, opts = {}) {
|
|
|
194
243
|
if (last.status !== null) {
|
|
195
244
|
const outcome = last.statusDetail ?? (last.status ? 'success' : 'failure');
|
|
196
245
|
console.log(chalk.dim(`Run finished: ${outcome}`));
|
|
246
|
+
// Pinch celebrates a clean run finish (#94) — mirrors doctor.ts's own
|
|
247
|
+
// `status === true` success definition (regardless of statusDetail),
|
|
248
|
+
// never for a failed/regression run. Neither `run --watch` nor
|
|
249
|
+
// `trigger --watch` (the only two callers) has a --json flag, so this
|
|
250
|
+
// is always safe to print. Guarded by pinchEnabled() (NO_COLOR/non-TTY).
|
|
251
|
+
if (last.status === true && pinchEnabled()) {
|
|
252
|
+
console.log(renderPinch('celebrating'));
|
|
253
|
+
}
|
|
197
254
|
return;
|
|
198
255
|
}
|
|
199
256
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -36,6 +36,15 @@ export declare function isEntryPoint(argv1: string | undefined, moduleUrl: strin
|
|
|
36
36
|
* subcommand cases)
|
|
37
37
|
*/
|
|
38
38
|
export declare function isHelpOrVersion(argv: string[]): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* True for a bare `trawl` invocation — no subcommand, no flags at all. This
|
|
41
|
+
* is the CLI's "first thing a new user sees" moment (commander prints its
|
|
42
|
+
* own top-level help right after), distinct from `isHelpOrVersion` which is
|
|
43
|
+
* intentionally broader (also matches `--help`/`--version`/`trawl help`
|
|
44
|
+
* anywhere in argv) — the Pinch wave banner (#94) only wants the narrowest
|
|
45
|
+
* case so it never shows up ahead of e.g. `trawl scraps --help`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isBareInvocation(argv: string[]): boolean;
|
|
39
48
|
/** Best-effort scan for a `--json` flag in raw argv, used only when parsing
|
|
40
49
|
* itself failed before any command's own `.opts()` could be resolved (a
|
|
41
50
|
* commander usage error — unknown option/command, missing required arg). Same
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { token } from './commands/token.js';
|
|
|
11
11
|
import { autoUpdateInstalledSkills } from './lib/skills.js';
|
|
12
12
|
import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
|
|
13
13
|
import { classifyError, reportError } from './lib/errors.js';
|
|
14
|
+
import { renderPinch, pinchEnabled } from './lib/pinch.js';
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
16
17
|
/**
|
|
@@ -96,6 +97,17 @@ export function isHelpOrVersion(argv) {
|
|
|
96
97
|
return true;
|
|
97
98
|
return false;
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* True for a bare `trawl` invocation — no subcommand, no flags at all. This
|
|
102
|
+
* is the CLI's "first thing a new user sees" moment (commander prints its
|
|
103
|
+
* own top-level help right after), distinct from `isHelpOrVersion` which is
|
|
104
|
+
* intentionally broader (also matches `--help`/`--version`/`trawl help`
|
|
105
|
+
* anywhere in argv) — the Pinch wave banner (#94) only wants the narrowest
|
|
106
|
+
* case so it never shows up ahead of e.g. `trawl scraps --help`.
|
|
107
|
+
*/
|
|
108
|
+
export function isBareInvocation(argv) {
|
|
109
|
+
return argv.slice(2).length === 0;
|
|
110
|
+
}
|
|
99
111
|
/** Best-effort scan for a `--json` flag in raw argv, used only when parsing
|
|
100
112
|
* itself failed before any command's own `.opts()` could be resolved (a
|
|
101
113
|
* commander usage error — unknown option/command, missing required arg). Same
|
|
@@ -171,6 +183,12 @@ export function bestEffortCommandName(argv, allowedNames) {
|
|
|
171
183
|
export async function runCli(argv = process.argv) {
|
|
172
184
|
if (!isHelpOrVersion(argv))
|
|
173
185
|
autoUpdateInstalledSkills();
|
|
186
|
+
// Pinch wave banner (#94) — bare `trawl` only, guarded so it never shows
|
|
187
|
+
// under NO_COLOR/non-TTY/piped output (pinchEnabled() covers all three).
|
|
188
|
+
if (isBareInvocation(argv) && pinchEnabled()) {
|
|
189
|
+
console.log(renderPinch('wave'));
|
|
190
|
+
console.log();
|
|
191
|
+
}
|
|
174
192
|
initPostHog();
|
|
175
193
|
const program = createProgram();
|
|
176
194
|
// Must run before parseAsync — installs on every node in the tree,
|
|
@@ -270,6 +288,12 @@ export async function runCli(argv = process.argv) {
|
|
|
270
288
|
// (stderr), never both; `quiet` skips the human line when the raw
|
|
271
289
|
// stack was already dumped above under --debug.
|
|
272
290
|
process.exitCode = reportError(err, { json: wantsJson, quiet: isDebug });
|
|
291
|
+
// Pinch confused frame (#94) — never under --json (stdout must stay
|
|
292
|
+
// pure JSON) and guarded by pinchEnabled() (NO_COLOR/non-TTY/piped).
|
|
293
|
+
// Printed to stderr, alongside reportError's own human line above.
|
|
294
|
+
if (!wantsJson && pinchEnabled()) {
|
|
295
|
+
console.error(renderPinch('confused'));
|
|
296
|
+
}
|
|
273
297
|
}
|
|
274
298
|
}
|
|
275
299
|
finally {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinch — Trawl's voxel lobster mascot, rendered as 24-bit ANSI half-block
|
|
3
|
+
* art. Distinct from Clawd's 8-bit lane: Pinch is drawn with full 24-bit
|
|
4
|
+
* (`\x1b[38;2;r;g;bm` / `\x1b[48;2;r;g;bm`) color blocks, not a fixed palette.
|
|
5
|
+
*
|
|
6
|
+
* The 12×9 cube-grid (AVGRID proto v11.4, locked 2026-07-16) is packed two
|
|
7
|
+
* grid rows into one terminal row: the upper row's color becomes the
|
|
8
|
+
* half-block's foreground, the lower row's becomes its background, using the
|
|
9
|
+
* upper-half-block glyph '▀' (or '▄' when only the lower half is filled). A
|
|
10
|
+
* 9-row grid therefore renders in 5 terminal rows, 12 columns wide. '.' cells
|
|
11
|
+
* are transparent — no color escape is emitted for that half, so the
|
|
12
|
+
* terminal's own background shows through.
|
|
13
|
+
*
|
|
14
|
+
* See comes-io/trawl_cli#94.
|
|
15
|
+
*/
|
|
16
|
+
export type PinchState = 'wave' | 'thinking' | 'celebrating' | 'confused';
|
|
17
|
+
/**
|
|
18
|
+
* Render Pinch as 24-bit ANSI half-block art for the given state, plus a
|
|
19
|
+
* one-line caption. Pure — never touches process.env/stdout; callers must
|
|
20
|
+
* gate on `pinchEnabled()` before printing the result.
|
|
21
|
+
*
|
|
22
|
+
* `frame` only affects 'thinking' (2-frame spinner alternation); every other
|
|
23
|
+
* state ignores it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function renderPinch(state: PinchState, frame?: number): string;
|
|
26
|
+
/**
|
|
27
|
+
* True when it's safe to print Pinch art: a real color-capable interactive
|
|
28
|
+
* terminal. False under NO_COLOR (https://no-color.org — presence, not
|
|
29
|
+
* value, disables color output), a non-TTY stdout (piped/redirected output —
|
|
30
|
+
* covers --json/--quiet/CI log capture), or TERM=dumb.
|
|
31
|
+
*/
|
|
32
|
+
export declare function pinchEnabled(): boolean;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinch — Trawl's voxel lobster mascot, rendered as 24-bit ANSI half-block
|
|
3
|
+
* art. Distinct from Clawd's 8-bit lane: Pinch is drawn with full 24-bit
|
|
4
|
+
* (`\x1b[38;2;r;g;bm` / `\x1b[48;2;r;g;bm`) color blocks, not a fixed palette.
|
|
5
|
+
*
|
|
6
|
+
* The 12×9 cube-grid (AVGRID proto v11.4, locked 2026-07-16) is packed two
|
|
7
|
+
* grid rows into one terminal row: the upper row's color becomes the
|
|
8
|
+
* half-block's foreground, the lower row's becomes its background, using the
|
|
9
|
+
* upper-half-block glyph '▀' (or '▄' when only the lower half is filled). A
|
|
10
|
+
* 9-row grid therefore renders in 5 terminal rows, 12 columns wide. '.' cells
|
|
11
|
+
* are transparent — no color escape is emitted for that half, so the
|
|
12
|
+
* terminal's own background shows through.
|
|
13
|
+
*
|
|
14
|
+
* See comes-io/trawl_cli#94.
|
|
15
|
+
*/
|
|
16
|
+
/** Grid-char → RGB. 'C' (cyan) is a "thinking" blip, not present in BASE_GRID. */
|
|
17
|
+
const PALETTE = {
|
|
18
|
+
B: [0x3b, 0x82, 0xf6], // blue — shell
|
|
19
|
+
O: [0xee, 0x90, 0x18], // orange — antennae / claws
|
|
20
|
+
W: [0xff, 0xff, 0xff], // white — eye whites
|
|
21
|
+
K: [0x12, 0x30, 0x33], // navy — pupils / mouth
|
|
22
|
+
P: [0xf9, 0xa8, 0xd4], // pink — claw tips
|
|
23
|
+
C: [0x08, 0x91, 0xb2], // cyan — thinking-spinner antenna blip
|
|
24
|
+
};
|
|
25
|
+
const TRANSPARENT = '.';
|
|
26
|
+
/** Base 12×9 grid — Pinch waving (also the 'wave' state, unmodified). */
|
|
27
|
+
const BASE_GRID = [
|
|
28
|
+
'...O....O...',
|
|
29
|
+
'...O....O...',
|
|
30
|
+
'.BBBBBBBBBB.',
|
|
31
|
+
'.BWWBBBBWWB.',
|
|
32
|
+
'.BWKBBBBKWB.',
|
|
33
|
+
'.PBBBBBBBBP.',
|
|
34
|
+
'.BBBKBBKBBB.',
|
|
35
|
+
'.BBBBKKBBBB.',
|
|
36
|
+
'OO........OO',
|
|
37
|
+
];
|
|
38
|
+
const CAPTIONS = {
|
|
39
|
+
wave: 'Pinch says hi.',
|
|
40
|
+
thinking: 'Pinch is thinking…',
|
|
41
|
+
celebrating: 'Pinch is celebrating!',
|
|
42
|
+
confused: 'Pinch looks confused.',
|
|
43
|
+
};
|
|
44
|
+
/** Replace the chars at `indices` in `row` with `ch` — never mutates `row`. */
|
|
45
|
+
function setCells(row, indices, ch) {
|
|
46
|
+
const chars = row.split('');
|
|
47
|
+
for (const i of indices)
|
|
48
|
+
chars[i] = ch;
|
|
49
|
+
return chars.join('');
|
|
50
|
+
}
|
|
51
|
+
/** Derive the per-state grid from BASE_GRID (which is never mutated). */
|
|
52
|
+
function gridForState(state, frame) {
|
|
53
|
+
const rows = [...BASE_GRID];
|
|
54
|
+
switch (state) {
|
|
55
|
+
case 'thinking':
|
|
56
|
+
// Odd frames: both antenna tips (row 0, cols 3 & 8) blip cyan — a
|
|
57
|
+
// 2-frame spinner alternation with no layout shift.
|
|
58
|
+
if (frame % 2 === 1) {
|
|
59
|
+
rows[0] = setCells(rows[0], [3, 8], 'C');
|
|
60
|
+
}
|
|
61
|
+
return rows;
|
|
62
|
+
case 'celebrating':
|
|
63
|
+
// Fists up: the row-8 corner claws move up to row 7's corners; row 8's
|
|
64
|
+
// corners go transparent (arms raised, no longer at the sides).
|
|
65
|
+
rows[7] = setCells(rows[7], [0, 11], 'O');
|
|
66
|
+
rows[8] = setCells(rows[8], [0, 1, 10, 11], TRANSPARENT);
|
|
67
|
+
return rows;
|
|
68
|
+
case 'confused':
|
|
69
|
+
// Pupils removed (row 4, cols 3 & 8) — blank white eyes.
|
|
70
|
+
rows[4] = setCells(rows[4], [3, 8], 'W');
|
|
71
|
+
return rows;
|
|
72
|
+
case 'wave':
|
|
73
|
+
default:
|
|
74
|
+
return rows;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const RESET = '\x1b[0m';
|
|
78
|
+
const fgCode = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
|
|
79
|
+
const bgCode = ([r, g, b]) => `\x1b[48;2;${r};${g};${b}m`;
|
|
80
|
+
/**
|
|
81
|
+
* One terminal column: combine an upper (fg-half) + lower (bg-half) grid
|
|
82
|
+
* cell into a single half-block character. Either half may be transparent
|
|
83
|
+
* independently — no bg/fg escape is emitted for a transparent half, so the
|
|
84
|
+
* terminal's own background/foreground shows through it.
|
|
85
|
+
*/
|
|
86
|
+
function renderCell(upper, lower) {
|
|
87
|
+
const upperColor = upper === TRANSPARENT ? null : PALETTE[upper];
|
|
88
|
+
const lowerColor = lower === TRANSPARENT ? null : PALETTE[lower];
|
|
89
|
+
if (!upperColor && !lowerColor)
|
|
90
|
+
return ' ';
|
|
91
|
+
// Lower half transparent — draw the upper half-block with fg only, no bg
|
|
92
|
+
// (the glyph's own unpainted lower half already shows the terminal bg).
|
|
93
|
+
if (!lowerColor)
|
|
94
|
+
return `${fgCode(upperColor)}▀${RESET}`;
|
|
95
|
+
// Upper half transparent — draw the LOWER half-block glyph instead, with
|
|
96
|
+
// fg set to the lower color and no bg, so the painted half is the bottom.
|
|
97
|
+
if (!upperColor)
|
|
98
|
+
return `${fgCode(lowerColor)}▄${RESET}`;
|
|
99
|
+
return `${fgCode(upperColor)}${bgCode(lowerColor)}▀${RESET}`;
|
|
100
|
+
}
|
|
101
|
+
/** Pack a 9-row grid into 5 terminal lines (rows 0-1, 2-3, 4-5, 6-7, 8-blank). */
|
|
102
|
+
function renderGrid(rows) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
for (let i = 0; i < rows.length; i += 2) {
|
|
105
|
+
const upperRow = rows[i];
|
|
106
|
+
const lowerRow = rows[i + 1] ?? TRANSPARENT.repeat(upperRow.length);
|
|
107
|
+
let line = '';
|
|
108
|
+
for (let c = 0; c < upperRow.length; c++) {
|
|
109
|
+
line += renderCell(upperRow[c], lowerRow[c]);
|
|
110
|
+
}
|
|
111
|
+
lines.push(line);
|
|
112
|
+
}
|
|
113
|
+
return lines;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Render Pinch as 24-bit ANSI half-block art for the given state, plus a
|
|
117
|
+
* one-line caption. Pure — never touches process.env/stdout; callers must
|
|
118
|
+
* gate on `pinchEnabled()` before printing the result.
|
|
119
|
+
*
|
|
120
|
+
* `frame` only affects 'thinking' (2-frame spinner alternation); every other
|
|
121
|
+
* state ignores it.
|
|
122
|
+
*/
|
|
123
|
+
export function renderPinch(state, frame = 0) {
|
|
124
|
+
const grid = gridForState(state, frame);
|
|
125
|
+
const lines = renderGrid(grid);
|
|
126
|
+
if (state === 'confused') {
|
|
127
|
+
// "beside the art" — a bold '?' to the right of the eye row (grid rows
|
|
128
|
+
// 4-5 pack into terminal line index 2).
|
|
129
|
+
lines[2] = `${lines[2]} \x1b[1m?${RESET}`;
|
|
130
|
+
}
|
|
131
|
+
return [...lines, CAPTIONS[state]].join('\n');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* True when it's safe to print Pinch art: a real color-capable interactive
|
|
135
|
+
* terminal. False under NO_COLOR (https://no-color.org — presence, not
|
|
136
|
+
* value, disables color output), a non-TTY stdout (piped/redirected output —
|
|
137
|
+
* covers --json/--quiet/CI log capture), or TERM=dumb.
|
|
138
|
+
*/
|
|
139
|
+
export function pinchEnabled() {
|
|
140
|
+
if (process.env['NO_COLOR'] !== undefined)
|
|
141
|
+
return false;
|
|
142
|
+
if (!process.stdout.isTTY)
|
|
143
|
+
return false;
|
|
144
|
+
if (process.env['TERM'] === 'dumb')
|
|
145
|
+
return false;
|
|
146
|
+
return true;
|
|
147
|
+
}
|