@trawlme/cli 3.1.0 → 3.2.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.
@@ -6,6 +6,7 @@ import { json } from '../lib/format.js';
6
6
  import { requireUrl, requireString } from '../lib/validate.js';
7
7
  import { UsageError } from '../lib/errors.js';
8
8
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
9
+ import { startPinchAnimation } from '../lib/pinchAnimation.js';
9
10
  /** Best-effort, honest first-run summary — never claims a background retry
10
11
  * happened when auto-fix was disabled for this call, and never claims a
11
12
  * scrap was persisted when the response carries none (#114-F3 — a hard
@@ -130,24 +131,25 @@ export const create = new Command('create')
130
131
  data = await call();
131
132
  }
132
133
  else {
133
- // #122 — Pinch shows up front, working, while the server-side wizard
134
- // runs (legitimately 30-250s+, see LONG_RUN_TIMEOUT_MS above). Belt-
135
- // and-suspenders `!opts.json` alongside pinchEnabled(): we're already
136
- // inside the non-`--json` branch, but the check is kept explicit here
137
- // too so stdout purity under `--json` (#106-F2/#121) never depends on
138
- // this code staying inside that branch.
139
- if (!opts.json && pinchEnabled())
140
- console.log(renderPinch('thinking'));
134
+ // #122/#131 — while the server-side wizard runs (legitimately 30-250s+,
135
+ // see LONG_RUN_TIMEOUT_MS above) Pinch ANIMATES in place (claw-wiggle)
136
+ // on a color-capable TTY; otherwise the ora spinner (which itself
137
+ // no-ops under a non-TTY, so piped human output stays clean). Both
138
+ // write to stderr only — stdout purity under `--json` is guaranteed by
139
+ // the `opts.json` branch above, never by this code.
140
+ const anim = pinchEnabled() ? startPinchAnimation(`Creating a scrap from ${url}…`) : null;
141
141
  try {
142
- data = await spin(call, {
143
- text: `Creating a scrap from ${url}…`,
144
- // No verdict symbol here (#106-F3) — ora's success only means "the
145
- // HTTP call didn't throw", not "the first run succeeded". The real
146
- // outcome renders below via the icon + the First run: line.
147
- successText: 'Request complete',
148
- });
142
+ data = anim
143
+ ? await call()
144
+ : await spin(call, {
145
+ // No verdict symbol (#106-F3) ora's success only means "the
146
+ // HTTP call didn't throw", not "the first run succeeded".
147
+ text: `Creating a scrap from ${url}…`,
148
+ successText: 'Request complete',
149
+ });
149
150
  }
150
151
  catch (err) {
152
+ anim?.stop();
151
153
  // #114-F1 — a client-side timeout (NetworkError, "timed out after
152
154
  // …ms" per api.ts's safeFetch) does NOT mean the wizard failed
153
155
  // server-side: the scrap creation + first run keep going on the
@@ -165,6 +167,9 @@ export const create = new Command('create')
165
167
  // stays intact (this stays a NetworkError -> exit 5, same as before).
166
168
  throw err;
167
169
  }
170
+ // Success — stop + erase the animation so the result prints on a clean
171
+ // line (the celebrating/confused frame below is the one-shot outcome).
172
+ anim?.stop();
168
173
  }
169
174
  if (opts.json) {
170
175
  json(data);
@@ -29,6 +29,23 @@ export type PinchState = 'wave' | 'thinking' | 'celebrating' | 'confused';
29
29
  * state's grid is static (see `gridForState`).
30
30
  */
31
31
  export declare function renderPinch(state: PinchState, frame?: number): string;
32
+ /**
33
+ * #131 — the ART lines only (no caption), optionally COMPACT: trailing and
34
+ * leading fully-transparent grid rows are dropped so Pinch takes fewer
35
+ * terminal lines (the r6f grids carry an all-'.' bottom row + blank-ish
36
+ * antenna padding). Used by the animation loop, where a caption + full
37
+ * height would be too heavy for a frame redrawn every ~380ms.
38
+ */
39
+ export declare function renderPinchArt(state: PinchState, opts?: {
40
+ compact?: boolean;
41
+ }): string;
42
+ /**
43
+ * #131 — frame sequence for the "working" animation shown during long waits
44
+ * (the `create` wizard, `--watch`). Reuses the existing r6f state grids: the
45
+ * claws pump up (wave → celebrating) and back down, reading as Pinch busily
46
+ * waving while it works. No new art — just an order over the grids we have.
47
+ */
48
+ export declare const WORKING_FRAMES: readonly PinchState[];
32
49
  /**
33
50
  * True when it's safe to print Pinch art: a real color-capable interactive
34
51
  * terminal. False under NO_COLOR (https://no-color.org — presence, not
package/dist/lib/pinch.js CHANGED
@@ -154,6 +154,37 @@ export function renderPinch(state, frame = 0) {
154
154
  }
155
155
  return [...lines, CAPTIONS[state]].join('\n');
156
156
  }
157
+ /**
158
+ * #131 — the ART lines only (no caption), optionally COMPACT: trailing and
159
+ * leading fully-transparent grid rows are dropped so Pinch takes fewer
160
+ * terminal lines (the r6f grids carry an all-'.' bottom row + blank-ish
161
+ * antenna padding). Used by the animation loop, where a caption + full
162
+ * height would be too heavy for a frame redrawn every ~380ms.
163
+ */
164
+ export function renderPinchArt(state, opts = {}) {
165
+ let grid = gridForState(state, 0);
166
+ if (opts.compact) {
167
+ const blank = (row) => [...row].every((c) => c === TRANSPARENT);
168
+ let start = 0;
169
+ let end = grid.length;
170
+ while (end > start && blank(grid[end - 1]))
171
+ end--;
172
+ while (start < end && blank(grid[start]))
173
+ start++;
174
+ grid = grid.slice(start, end);
175
+ }
176
+ const lines = renderGrid(grid);
177
+ if (state === 'confused')
178
+ lines[2] = `${lines[2]} \x1b[1m?${RESET}`;
179
+ return lines.join('\n');
180
+ }
181
+ /**
182
+ * #131 — frame sequence for the "working" animation shown during long waits
183
+ * (the `create` wizard, `--watch`). Reuses the existing r6f state grids: the
184
+ * claws pump up (wave → celebrating) and back down, reading as Pinch busily
185
+ * waving while it works. No new art — just an order over the grids we have.
186
+ */
187
+ export const WORKING_FRAMES = ['thinking', 'wave', 'celebrating', 'wave'];
157
188
  /**
158
189
  * True when it's safe to print Pinch art: a real color-capable interactive
159
190
  * terminal. False under NO_COLOR (https://no-color.org — presence, not
@@ -0,0 +1,17 @@
1
+ export interface PinchAnimation {
2
+ /** Stop the loop and erase the whole animation block, leaving the cursor at
3
+ * its top-left so the caller prints its result on a clean line. Idempotent. */
4
+ stop(): void;
5
+ }
6
+ /**
7
+ * #131 — animate Pinch (a claw-wiggle over the r6f grids) IN PLACE on STDERR
8
+ * while a long operation runs (the `create` wizard, `--watch`). The frame is
9
+ * redrawn every {@link FRAME_MS} via ANSI cursor moves; `stop()` clears it.
10
+ *
11
+ * Contract: the CALLER must have already checked `pinchEnabled()` (TTY /
12
+ * !NO_COLOR) and that the command is NOT `--json` — this writes only to
13
+ * STDERR, so stdout stays pure regardless, but the animation is human-only.
14
+ * The interval is `unref()`'d so it can never keep the process alive on its
15
+ * own.
16
+ */
17
+ export declare function startPinchAnimation(statusText: string): PinchAnimation;
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk';
2
+ import { renderPinchArt, WORKING_FRAMES } from './pinch.js';
3
+ /** Frame cadence — slow enough to read as a wave, fast enough to feel alive. */
4
+ const FRAME_MS = 380;
5
+ /**
6
+ * #131 — animate Pinch (a claw-wiggle over the r6f grids) IN PLACE on STDERR
7
+ * while a long operation runs (the `create` wizard, `--watch`). The frame is
8
+ * redrawn every {@link FRAME_MS} via ANSI cursor moves; `stop()` clears it.
9
+ *
10
+ * Contract: the CALLER must have already checked `pinchEnabled()` (TTY /
11
+ * !NO_COLOR) and that the command is NOT `--json` — this writes only to
12
+ * STDERR, so stdout stays pure regardless, but the animation is human-only.
13
+ * The interval is `unref()`'d so it can never keep the process alive on its
14
+ * own.
15
+ */
16
+ export function startPinchAnimation(statusText) {
17
+ const frames = WORKING_FRAMES.map((s) => renderPinchArt(s, { compact: true }));
18
+ const artHeight = frames[0].split('\n').length;
19
+ const totalHeight = artHeight + 1; // + the status line below the art
20
+ const out = process.stderr;
21
+ let i = 0;
22
+ const block = (frame) => `${frame}\n${chalk.dim(statusText)}\n`;
23
+ const cursorUp = (n) => {
24
+ if (n > 0)
25
+ out.write(`\x1b[${n}A`);
26
+ };
27
+ // Redraw a frame over the previous one — clear each line to EOL first so a
28
+ // narrower frame can't leave trailing pixels from a wider one.
29
+ const redraw = (frame) => {
30
+ const lines = `${frame}\n${chalk.dim(statusText)}`.split('\n');
31
+ out.write(lines.map((l) => `\x1b[2K${l}`).join('\n') + '\n');
32
+ };
33
+ out.write('\x1b[?25l'); // hide cursor for a flicker-free redraw
34
+ out.write(block(frames[0]));
35
+ const timer = setInterval(() => {
36
+ i += 1;
37
+ cursorUp(totalHeight);
38
+ redraw(frames[i % frames.length]);
39
+ }, FRAME_MS);
40
+ if (typeof timer.unref === 'function')
41
+ timer.unref();
42
+ let stopped = false;
43
+ return {
44
+ stop() {
45
+ if (stopped)
46
+ return;
47
+ stopped = true;
48
+ clearInterval(timer);
49
+ cursorUp(totalHeight); // back to the top of the block
50
+ for (let l = 0; l < totalHeight; l++)
51
+ out.write('\x1b[2K\n'); // erase each line
52
+ cursorUp(totalHeight); // back to top so the caller's next print lands here
53
+ out.write('\x1b[?25h'); // restore the cursor
54
+ },
55
+ };
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {