@remotedraw/cli 0.1.2 → 0.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.
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { emitKeypressEvents } from "node:readline";
3
+ import { t } from "./i18n.js";
3
4
  export function initialWizardState(definition) {
4
5
  return {
5
6
  screen: "field",
@@ -118,6 +119,17 @@ export function transitionWizard(state, action, definition) {
118
119
  }
119
120
  return state;
120
121
  }
122
+ function completedSteps(state, definition) {
123
+ return definition.fields
124
+ .slice(0, state.fieldIndex)
125
+ .map((field) => {
126
+ const value = state.values[field.key];
127
+ const label = field
128
+ .choices?.(state.values)
129
+ .find((option) => option.value === value)?.label;
130
+ return [field.label, label ?? value];
131
+ });
132
+ }
121
133
  export function wizardSemanticView(state, definition) {
122
134
  if (state.screen === "field") {
123
135
  const field = activeField(state, definition);
@@ -125,7 +137,7 @@ export function wizardSemanticView(state, definition) {
125
137
  const selectedChoice = choices?.find((choice) => choice.value === state.values[field.key]);
126
138
  return {
127
139
  screen: state.screen,
128
- title: "Een RemoteDraw-project maken",
140
+ title: t("wizard.title.setup"),
129
141
  progress: {
130
142
  current: state.fieldIndex + 1,
131
143
  total: definition.fields.length,
@@ -133,6 +145,8 @@ export function wizardSemanticView(state, definition) {
133
145
  activeLabel: field.label,
134
146
  value: state.values[field.key],
135
147
  valueIsDefault: field.kind === "text" && !state.editedFields.includes(field.key),
148
+ valuePreview: field.kind === "text" ? field.preview?.(state.values) : undefined,
149
+ completed: completedSteps(state, definition),
136
150
  choices,
137
151
  selectedChoice: state.values[field.key],
138
152
  selectedDetails: selectedChoice?.details,
@@ -140,54 +154,63 @@ export function wizardSemanticView(state, definition) {
140
154
  controls: field.kind === "text"
141
155
  ? [
142
156
  !state.editedFields.includes(field.key)
143
- ? "Typ om de standaardnaam te vervangen"
144
- : "Typ om te wijzigen",
145
- "←/→ stappen",
146
- "Enter volgende",
147
- "Esc terug",
148
- "Ctrl+C stoppen",
157
+ ? t("wizard.hint.replaceDefault")
158
+ : t("wizard.hint.edit"),
159
+ t("wizard.control.steps"),
160
+ t("wizard.control.next"),
161
+ t("wizard.control.back"),
162
+ t("wizard.control.quitCtrlC"),
149
163
  ]
150
164
  : [
151
- "↑/↓ kiezen",
152
- ...(selectedChoice?.details ? ["d docs openen"] : []),
153
- "←/→ stappen",
154
- "Enter volgende",
155
- "Esc terug",
156
- "q stoppen",
165
+ t("wizard.control.choose"),
166
+ ...(selectedChoice?.details ? [t("wizard.control.docs")] : []),
167
+ t("wizard.control.steps"),
168
+ t("wizard.control.next"),
169
+ t("wizard.control.back"),
170
+ t("wizard.control.quitQ"),
157
171
  ],
158
172
  };
159
173
  }
160
174
  if (state.screen === "review") {
161
175
  return {
162
176
  screen: state.screen,
163
- title: "Project controleren",
177
+ title: t("wizard.title.review"),
164
178
  review: definition.review(state.values),
165
- controls: ["→/Enter doorgaan", "←/Esc terug", "q stoppen"],
179
+ controls: [
180
+ t("wizard.control.forward"),
181
+ t("wizard.control.backArrow"),
182
+ t("wizard.control.quitQ"),
183
+ ],
166
184
  };
167
185
  }
168
186
  if (state.screen === "confirm") {
169
187
  return {
170
188
  screen: state.screen,
171
- title: "Projectbestanden maken?",
189
+ title: t("wizard.title.confirm"),
190
+ review: definition.review(state.values),
172
191
  choices: [
173
- { value: "no", label: "Nee, annuleren" },
174
- { value: "yes", label: "Ja, project maken" },
192
+ { value: "no", label: t("wizard.confirm.no") },
193
+ { value: "yes", label: t("wizard.confirm.yes") },
175
194
  ],
176
195
  selectedChoice: state.confirmCreate ? "yes" : "no",
177
- controls: ["↑/↓ kiezen", "Enter bevestigen", "←/Esc terug"],
196
+ controls: [
197
+ t("wizard.control.choose"),
198
+ t("wizard.control.confirm"),
199
+ t("wizard.control.backArrow"),
200
+ ],
178
201
  };
179
202
  }
180
203
  return {
181
204
  screen: state.screen,
182
205
  title: state.screen === "executing"
183
- ? "Project wordt gemaakt…"
206
+ ? t("wizard.title.executing")
184
207
  : state.screen === "success"
185
- ? "RemoteDraw-project gemaakt"
208
+ ? t("wizard.title.success")
186
209
  : state.screen === "error"
187
- ? "Project maken mislukt"
210
+ ? t("wizard.title.error")
188
211
  : state.screen === "interrupted"
189
- ? "Installatie onderbroken"
190
- : "Installatie geannuleerd",
212
+ ? t("wizard.title.interrupted")
213
+ : t("wizard.title.cancelled"),
191
214
  controls: [],
192
215
  createdPath: state.createdPath,
193
216
  nextCommand: state.nextCommand,
@@ -196,39 +219,86 @@ export function wizardSemanticView(state, definition) {
196
219
  }
197
220
  export async function runSetupWizard(options) {
198
221
  let state = initialWizardState(options.definition);
199
- try {
200
- await options.terminal.enter();
201
- if (options.terminal.color) {
202
- for (let frame = 0; frame < BANNER_ANIMATION_FRAMES; frame += 1) {
203
- options.terminal.write(renderWizard(state, options.definition, options.terminal, frame));
204
- if (frame < BANNER_ANIMATION_FRAMES - 1) {
205
- await pause(BANNER_ANIMATION_DELAY_MS);
206
- }
222
+ const animated = options.terminal.color;
223
+ // Monotonic so the wordmark, caret, and spinner never jump backwards.
224
+ let tick = IDLE_FRAME;
225
+ const paint = (frame) => {
226
+ options.terminal.write(renderWizard(state, options.definition, options.terminal, frame));
227
+ };
228
+ const play = async (frames, delay, from) => {
229
+ if (from != null)
230
+ tick = from;
231
+ for (let frame = 0; frame < frames; frame += 1) {
232
+ if (options.terminal.hasPendingKey?.() === true)
233
+ break;
234
+ paint(from != null ? tick + frame : (tick += 1));
235
+ if (frame < frames - 1)
236
+ await pause(delay);
237
+ }
238
+ if (from != null)
239
+ tick = from + frames - 1;
240
+ };
241
+ /** Waits for a key while keeping the frame alive, then goes quiet. */
242
+ const nextKey = async () => {
243
+ const readWithin = options.terminal.readKeyWithin;
244
+ if (animated && readWithin) {
245
+ for (let idle = 0; idle < IDLE_TICKS; idle += 1) {
246
+ const key = await readWithin(IDLE_TICK_MS);
247
+ if (key)
248
+ return key;
249
+ tick += 1;
250
+ paint(tick);
207
251
  }
208
252
  }
253
+ return await options.terminal.readKey();
254
+ };
255
+ try {
256
+ await options.terminal.enter();
257
+ if (animated)
258
+ await play(INTRO_FRAMES, INTRO_FRAME_MS, 0);
259
+ let previousStep = stepSignature(state);
209
260
  while (true) {
210
- options.terminal.write(renderWizard(state, options.definition, options.terminal, BANNER_ANIMATION_FRAMES - 1));
261
+ const currentStep = stepSignature(state);
262
+ if (animated && currentStep !== previousStep) {
263
+ await play(STEP_FRAMES, STEP_FRAME_MS);
264
+ }
265
+ previousStep = currentStep;
266
+ paint(tick);
211
267
  if (state.screen === "executing") {
212
- try {
213
- const result = await options.execute(state.values);
214
- state = transitionWizard(state, { type: "executionSucceeded", ...result }, options.definition);
268
+ let running = true;
269
+ const execution = options
270
+ .execute(state.values)
271
+ .then((result) => ({ ok: true, result }), (error) => ({ ok: false, error }))
272
+ .finally(() => {
273
+ running = false;
274
+ });
275
+ if (animated) {
276
+ while (running) {
277
+ tick += 1;
278
+ paint(tick);
279
+ await pause(SPINNER_FRAME_MS);
280
+ }
215
281
  }
216
- catch (error) {
217
- state = transitionWizard(state, {
282
+ const outcome = await execution;
283
+ state = outcome.ok
284
+ ? transitionWizard(state, { type: "executionSucceeded", ...outcome.result }, options.definition)
285
+ : transitionWizard(state, {
218
286
  type: "executionFailed",
219
- message: `${error instanceof Error ? error.message : String(error)}\nControleer de doelmap en de toegangsrechten.`,
287
+ message: `${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}\nControleer de doelmap en de toegangsrechten.`,
220
288
  }, options.definition);
221
- }
222
289
  continue;
223
290
  }
224
291
  if (state.screen === "success" || state.screen === "cancelled") {
292
+ if (animated && state.screen === "success") {
293
+ await play(SUCCESS_FRAMES, SUCCESS_FRAME_MS);
294
+ }
225
295
  return { exitCode: 0, state };
226
296
  }
227
297
  if (state.screen === "error")
228
298
  return { exitCode: 1, state };
229
299
  if (state.screen === "interrupted")
230
300
  return { exitCode: 130, state };
231
- const key = await options.terminal.readKey();
301
+ const key = await nextKey();
232
302
  const selectedDetails = wizardSemanticView(state, options.definition).selectedDetails;
233
303
  if (key.name === "d" && selectedDetails) {
234
304
  if (!options.terminal.openUrl) {
@@ -244,7 +314,7 @@ export async function runSetupWizard(options) {
244
314
  catch {
245
315
  state = {
246
316
  ...state,
247
- error: `Kon de documentatie niet openen. Gebruik ${selectedDetails.docsUrl}`,
317
+ error: t("wizard.docs.openFailed", { url: selectedDetails.docsUrl }),
248
318
  };
249
319
  }
250
320
  continue;
@@ -287,8 +357,29 @@ export function actionForKey(key, state) {
287
357
  }
288
358
  return { type: "character", value: "" };
289
359
  }
290
- function paint(enabled, code, value) {
291
- return enabled ? `\x1b[${code}m${value}\x1b[0m` : value;
360
+ const INK = {
361
+ accent: { rgb: [56, 189, 248], code: "36" },
362
+ glow: { rgb: [186, 230, 253], code: "96" },
363
+ brand: { rgb: [129, 140, 248], code: "94" },
364
+ muted: { rgb: [124, 134, 150], code: "2" },
365
+ success: { rgb: [52, 211, 153], code: "32" },
366
+ danger: { rgb: [248, 113, 113], code: "31" },
367
+ warn: { rgb: [251, 191, 36], code: "33" },
368
+ };
369
+ function ink(terminal, tone, value, heavy = false) {
370
+ if (!terminal.color || value.length === 0)
371
+ return value;
372
+ const entry = INK[tone];
373
+ const sequence = terminal.trueColor
374
+ ? truecolor(entry.rgb)
375
+ : `\x1b[${entry.code}m`;
376
+ return `${heavy ? "\x1b[1m" : ""}${sequence}${value}\x1b[0m`;
377
+ }
378
+ function strong(terminal, value) {
379
+ return terminal.color && value.length > 0 ? `\x1b[1m${value}\x1b[0m` : value;
380
+ }
381
+ function truecolor(rgb) {
382
+ return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
292
383
  }
293
384
  function hyperlink(enabled, url, label) {
294
385
  return enabled ? `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\` : label;
@@ -296,7 +387,7 @@ function hyperlink(enabled, url, label) {
296
387
  async function openExternalUrl(url) {
297
388
  const parsed = new URL(url);
298
389
  if (parsed.protocol !== "https:") {
299
- throw new Error("Only HTTPS documentation links can be opened.");
390
+ throw new Error(t("error.httpsDocsOnly"));
300
391
  }
301
392
  const [command, args] = process.platform === "darwin"
302
393
  ? ["open", [url]]
@@ -346,102 +437,468 @@ function wrapText(value, width) {
346
437
  lines.push(line);
347
438
  return lines;
348
439
  }
440
+ function cardLines(terminal, title, rows, width) {
441
+ const border = (value) => ink(terminal, "accent", value);
442
+ const header = `─ ${title} `;
443
+ const lines = [
444
+ `${border("╭")}${ink(terminal, "glow", header, true)}${border("─".repeat(Math.max(0, width + 2 - header.length)))}${border("╮")}`,
445
+ ];
446
+ for (const row of rows) {
447
+ const fitted = row.text.length > width
448
+ ? { text: `${row.text.slice(0, Math.max(1, width - 1))}…` }
449
+ : row;
450
+ lines.push(`${border("│")} ${fitted.rendered ?? fitted.text}${" ".repeat(Math.max(0, width - fitted.text.length))} ${border("│")}`);
451
+ }
452
+ lines.push(`${border("╰")}${border("─".repeat(width + 2))}${border("╯")}`);
453
+ return lines;
454
+ }
349
455
  function renderChoiceDetails(details, terminal) {
350
456
  const width = Math.max(20, Math.min(72, terminal.columns - 4));
351
- const border = (value) => paint(terminal.color, "36", value);
352
- const contentLine = (value = "", rendered = value) => `${border("│")} ${rendered}${" ".repeat(Math.max(0, width - value.length))} ${border("│")}`;
353
457
  const button = `[ ${details.docsLabel} ↗ ]`;
354
- const header = "─ Keuzehulp ";
355
- const lines = [
356
- `${border("┌")}${paint(terminal.color, "1;36", header)}${border("─".repeat(width + 2 - header.length))}${border("┐")}`,
357
- ...wrapText(details.summary, width).map((line) => contentLine(line, paint(terminal.color, "1", line))),
358
- contentLine(),
359
- ...wrapText(details.explanation, width).map((line) => contentLine(line)),
360
- contentLine(),
361
- contentLine(button, hyperlink(terminal.hyperlinks === true, details.docsUrl, paint(terminal.color, "1;96", button))),
458
+ const rows = [
459
+ ...wrapText(details.summary, width).map((line) => ({
460
+ text: line,
461
+ rendered: strong(terminal, line),
462
+ })),
463
+ { text: "" },
464
+ ...wrapText(details.explanation, width).map((line) => ({ text: line })),
465
+ { text: "" },
466
+ {
467
+ text: button,
468
+ rendered: hyperlink(terminal.hyperlinks === true, details.docsUrl, ink(terminal, "glow", button, true)),
469
+ },
362
470
  ];
363
471
  if (terminal.hyperlinks !== true) {
364
- lines.push(...wrapText(details.docsUrl, width).map((line) => contentLine(line, paint(terminal.color, "2", line))));
472
+ rows.push(...wrapText(details.docsUrl, width).map((line) => ({
473
+ text: line,
474
+ rendered: ink(terminal, "muted", line),
475
+ })));
365
476
  }
366
- lines.push(`${border("")}${border("─".repeat(width + 2))}${border("┘")}`);
367
- return lines;
477
+ return cardLines(terminal, t("wizard.card.help"), rows, width);
368
478
  }
369
479
  const BANNER_COLORS = ["36", "96", "94", "34"];
370
- const BANNER_ANIMATION_FRAMES = BANNER_COLORS.length;
371
- const BANNER_ANIMATION_DELAY_MS = 45;
480
+ const BANNER_GRADIENT = [
481
+ [45, 212, 191],
482
+ [56, 189, 248],
483
+ [99, 130, 246],
484
+ [129, 140, 248],
485
+ ];
486
+ const BANNER_GHOST = [42, 51, 66];
487
+ const BANNER_CREST = [236, 254, 255];
488
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
489
+ const SPARKLES = ["✦", "✧", "✶", "✧"];
490
+ const INTRO_FRAMES = 28;
491
+ const INTRO_FRAME_MS = 22;
492
+ const IDLE_FRAME = INTRO_FRAMES - 1;
493
+ const REVEAL_SPEED = 0.06;
494
+ const CREST_WIDTH = 0.22;
495
+ const SHIMMER_WIDTH = 0.2;
496
+ const SHIMMER_SPAN = 14;
497
+ const SHIMMER_PERIOD = 52;
498
+ const STEP_FRAMES = 4;
499
+ const STEP_FRAME_MS = 26;
500
+ const SPINNER_FRAME_MS = 80;
501
+ const SUCCESS_FRAMES = 6;
502
+ const SUCCESS_FRAME_MS = 55;
503
+ const IDLE_TICK_MS = 150;
504
+ const IDLE_TICKS = 60;
372
505
  function pause(milliseconds) {
373
506
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
374
507
  }
375
- export function renderWizard(state, definition, terminal, bannerFrame = 0) {
508
+ function stepSignature(state) {
509
+ return `${state.screen}:${state.fieldIndex}`;
510
+ }
511
+ /** Steady while the frame is still settling; blinks once the wizard idles. */
512
+ function caretGlyph(frame) {
513
+ const idleTicks = frame - IDLE_FRAME;
514
+ if (idleTicks <= 0)
515
+ return "▌";
516
+ return Math.floor(idleTicks / 3) % 2 === 0 ? "▌" : " ";
517
+ }
518
+ const WORDMARK = "RemoteDraw";
519
+ /** Pixel rows per glyph; rendered as solid blocks or half blocks. */
520
+ const BOLD_GLYPHS = {
521
+ R: ["######.", "##...##", "##...##", "######.", "##..##.", "##...##"],
522
+ D: ["######.", "##...##", "##...##", "##...##", "##...##", "######."],
523
+ e: ["......", ".####.", "##..##", "######", "##....", ".####."],
524
+ m: [
525
+ "..........",
526
+ "##########",
527
+ "##..##..##",
528
+ "##..##..##",
529
+ "##..##..##",
530
+ "##..##..##",
531
+ ],
532
+ o: ["......", ".####.", "##..##", "##..##", "##..##", ".####."],
533
+ t: [".##..", ".##..", "#####", ".##..", ".##..", "..###"],
534
+ r: ["......", "##.###", "####..", "##....", "##....", "##...."],
535
+ a: ["......", ".####.", "....##", ".#####", "##..##", ".#####"],
536
+ w: ["........", "##....##", "##....##", "##.##.##", "##.##.##", ".##..##."],
537
+ };
538
+ const COMPACT_GLYPHS = {
539
+ R: ["####.", "#...#", "#...#", "####.", "#..#.", "#...#"],
540
+ D: ["####.", "#...#", "#...#", "#...#", "#...#", "####."],
541
+ e: [".....", ".###.", "#...#", "#####", "#....", ".###."],
542
+ m: [".....", "#####", "#.#.#", "#.#.#", "#.#.#", "#.#.#"],
543
+ o: [".....", ".###.", "#...#", "#...#", "#...#", ".###."],
544
+ t: [".#...", ".#...", "####.", ".#...", ".#...", "..##."],
545
+ r: [".....", "#.###", "##...", "#....", "#....", "#...."],
546
+ a: [".....", ".###.", "....#", ".####", "#...#", ".####"],
547
+ w: [".....", "#...#", "#...#", "#.#.#", "#.#.#", ".#.#."],
548
+ };
549
+ const BOLD_WIDTH = wordmarkWidth(BOLD_GLYPHS);
550
+ const COMPACT_WIDTH = wordmarkWidth(COMPACT_GLYPHS);
551
+ const BANNER_CACHE = new Map();
552
+ function wordmarkWidth(glyphs) {
553
+ return [...WORDMARK].reduce((total, character) => total + (glyphs[character]?.[0]?.length ?? 0) + 1, -1);
554
+ }
555
+ /** Full pixel rows: one text row per pixel row, solid blocks. */
556
+ function solidWordmark(glyphs) {
557
+ return Array.from({ length: 6 }, (_unused, row) => [...WORDMARK]
558
+ .map((character) => [...(glyphs[character]?.[row] ?? "")]
559
+ .map((pixel) => (pixel === "#" ? "█" : " "))
560
+ .join(""))
561
+ .join(" "));
562
+ }
563
+ /** Half-height: two pixel rows folded into one text row. */
564
+ function halfWordmark(glyphs) {
565
+ return Array.from({ length: 3 }, (_unused, row) => [...WORDMARK]
566
+ .map((character) => {
567
+ const top = glyphs[character]?.[row * 2] ?? "";
568
+ const bottom = glyphs[character]?.[row * 2 + 1] ?? "";
569
+ return [...top]
570
+ .map((pixel, column) => {
571
+ const upper = pixel === "#";
572
+ const lower = bottom[column] === "#";
573
+ return upper && lower ? "█" : upper ? "▀" : lower ? "▄" : " ";
574
+ })
575
+ .join("");
576
+ })
577
+ .join(" "));
578
+ }
579
+ function wordmarkRows(level, columns) {
580
+ const bold = columns >= BOLD_WIDTH + 2;
581
+ const key = `${level}:${bold}`;
582
+ const cached = BANNER_CACHE.get(key);
583
+ if (cached)
584
+ return cached;
585
+ const glyphs = bold ? BOLD_GLYPHS : COMPACT_GLYPHS;
586
+ const rows = level === 2 ? solidWordmark(glyphs) : halfWordmark(glyphs);
587
+ BANNER_CACHE.set(key, rows);
588
+ return rows;
589
+ }
590
+ function bannerLines(terminal, frame, level) {
591
+ if (level === 0)
592
+ return [ink(terminal, "glow", WORDMARK, true)];
593
+ const rows = wordmarkRows(level, terminal.columns);
594
+ return rows.map((row) => paintWordmark(terminal, row, frame));
595
+ }
596
+ /**
597
+ * The wordmark materialises: a crest sweeps left to right during the intro,
598
+ * turning dim scaffolding into lit blocks. Once settled, a slower highlight
599
+ * passes over it every few seconds while the wizard waits for input.
600
+ */
601
+ function paintWordmark(terminal, row, frame) {
602
+ if (!terminal.color)
603
+ return row;
604
+ const characters = [...row];
605
+ const span = Math.max(1, characters.length - 1);
606
+ const crest = frame * REVEAL_SPEED - 0.3;
607
+ const idleTicks = frame - IDLE_FRAME;
608
+ const shimmerPhase = idleTicks > 0 ? idleTicks % SHIMMER_PERIOD : -1;
609
+ const shimmer = shimmerPhase >= 0 && shimmerPhase < SHIMMER_SPAN
610
+ ? (shimmerPhase / SHIMMER_SPAN) * 1.35 - 0.18
611
+ : undefined;
612
+ let rendered = "";
613
+ let openSequence = "";
614
+ let buffered = "";
615
+ for (let index = 0; index < characters.length; index += 1) {
616
+ const position = index / span;
617
+ const sequence = wordmarkSequence(terminal, position, crest, shimmer);
618
+ if (sequence !== openSequence) {
619
+ if (buffered)
620
+ rendered += `${openSequence}${buffered}`;
621
+ openSequence = sequence;
622
+ buffered = "";
623
+ }
624
+ buffered += characters[index];
625
+ }
626
+ return `${rendered}${openSequence}${buffered}\x1b[0m`;
627
+ }
628
+ function wordmarkSequence(terminal, position, crest, shimmer) {
629
+ const arrived = crest - position;
630
+ const highlight = Math.max(arrived >= 0 && arrived < CREST_WIDTH ? 1 - arrived / CREST_WIDTH : 0, shimmer == null
631
+ ? 0
632
+ : Math.max(0, 1 - Math.abs(position - shimmer) / SHIMMER_WIDTH));
633
+ if (!terminal.trueColor) {
634
+ if (arrived < 0)
635
+ return "\x1b[2m\x1b[34m";
636
+ if (highlight > 0.45)
637
+ return "\x1b[96m";
638
+ return `\x1b[${BANNER_COLORS[Math.floor(position * 3.5) % BANNER_COLORS.length]}m`;
639
+ }
640
+ if (arrived < 0)
641
+ return truecolor(BANNER_GHOST);
642
+ const scaled = Math.min(0.999, position) * (BANNER_GRADIENT.length - 1);
643
+ const index = Math.floor(scaled);
644
+ const base = mixRgb(BANNER_GRADIENT[index], BANNER_GRADIENT[index + 1], scaled - index);
645
+ return truecolor(highlight > 0
646
+ ? mixRgb(base, BANNER_CREST, highlight * highlight * 0.9)
647
+ : base);
648
+ }
649
+ function mixRgb(from, to, amount) {
650
+ const ratio = Math.min(1, Math.max(0, amount));
651
+ return [
652
+ Math.round(from[0] + (to[0] - from[0]) * ratio),
653
+ Math.round(from[1] + (to[1] - from[1]) * ratio),
654
+ Math.round(from[2] + (to[2] - from[2]) * ratio),
655
+ ];
656
+ }
657
+ function stepRail(terminal, current, total, frame) {
658
+ const cells = [];
659
+ for (let index = 0; index < total; index += 1) {
660
+ if (index > 0) {
661
+ cells.push(ink(terminal, index < current ? "accent" : "muted", "─"));
662
+ }
663
+ if (index < current - 1) {
664
+ cells.push(ink(terminal, "accent", "●"));
665
+ }
666
+ else if (index === current - 1) {
667
+ cells.push(ink(terminal, frame % 2 === 0 ? "glow" : "accent", "◉", true));
668
+ }
669
+ else {
670
+ cells.push(ink(terminal, "muted", "○"));
671
+ }
672
+ }
673
+ return cells.join("");
674
+ }
675
+ function screenGlyph(terminal, screen, frame) {
676
+ if (screen === "executing") {
677
+ return ink(terminal, "accent", SPINNER[frame % SPINNER.length]);
678
+ }
679
+ if (screen === "success")
680
+ return ink(terminal, "success", "✓", true);
681
+ if (screen === "error")
682
+ return ink(terminal, "danger", "✗", true);
683
+ if (screen === "confirm")
684
+ return ink(terminal, "warn", "?", true);
685
+ if (screen === "cancelled" || screen === "interrupted") {
686
+ return ink(terminal, "warn", "○");
687
+ }
688
+ return ink(terminal, "accent", "▸");
689
+ }
690
+ function choiceLines(terminal, choices, selectedValue, withHints) {
691
+ const lines = [];
692
+ for (const choice of choices) {
693
+ const selected = choice.value === selectedValue;
694
+ lines.push(selected
695
+ ? `${ink(terminal, "glow", "❯")} ${ink(terminal, "glow", "◉")} ${strong(terminal, choice.label)}`
696
+ : ` ${ink(terminal, "muted", "○")} ${choice.label}`);
697
+ if (withHints && choice.hint) {
698
+ lines.push(` ${ink(terminal, "muted", choice.hint)}`);
699
+ }
700
+ }
701
+ return lines;
702
+ }
703
+ function progressBar(terminal, frame, size) {
704
+ const head = frame % (size + 6);
705
+ const cells = Array.from({ length: size }, (_unused, index) => {
706
+ const distance = head - index;
707
+ if (distance < 0 || distance > 5)
708
+ return ink(terminal, "muted", "▱");
709
+ return ink(terminal, distance < 2 ? "glow" : "accent", "▰");
710
+ });
711
+ return ` ${cells.join("")}`;
712
+ }
713
+ function controlLines(terminal, controls, width) {
714
+ const separator = ink(terminal, "muted", " · ");
715
+ const lines = [];
716
+ let rendered = "";
717
+ let plainLength = 0;
718
+ for (const control of controls) {
719
+ if (plainLength > 0 && plainLength + 3 + control.length > width) {
720
+ lines.push(rendered);
721
+ rendered = "";
722
+ plainLength = 0;
723
+ }
724
+ if (plainLength === 0) {
725
+ rendered = controlChip(terminal, control);
726
+ plainLength = control.length;
727
+ }
728
+ else {
729
+ rendered += `${separator}${controlChip(terminal, control)}`;
730
+ plainLength += 3 + control.length;
731
+ }
732
+ }
733
+ if (rendered)
734
+ lines.push(rendered);
735
+ return lines;
736
+ }
737
+ function controlChip(terminal, control) {
738
+ const gap = control.indexOf(" ");
739
+ if (gap < 0)
740
+ return ink(terminal, "glow", control);
741
+ return `${ink(terminal, "glow", control.slice(0, gap))}${ink(terminal, "muted", control.slice(gap))}`;
742
+ }
743
+ function labelColumn(pairs) {
744
+ return pairs.reduce((widest, [label]) => Math.max(widest, label.length), 0);
745
+ }
746
+ export function renderWizard(state, definition, terminal, frame = 0) {
376
747
  const view = wizardSemanticView(state, definition);
377
748
  const narrow = terminal.columns < 60;
378
- const wordmark = narrow
379
- ? ["RemoteDraw", "telefoon canvas"]
380
- : [
381
- " ____ _ ____ ",
382
- "| _ \\ ___ _ __ ___ ___ | |_ ___| _ \\ _ __ __ ___ __",
383
- "| |_) / _ \\ '_ ` _ \\ / _ \\| __/ _ \\ | | | '__/ _` \\ \\ /\\ / /",
384
- "| _ < __/ | | | | | (_) | || __/ |_| | | | (_| |\\ V V / ",
385
- "|_| \\_\\___|_| |_| |_|\\___/ \\__\\___|____/|_| \\__,_| \\_/\\_/ ",
386
- ];
749
+ const rule = Math.max(12, Math.min(terminal.columns - 1, 72));
750
+ const lines = fitWizard(view, terminal, frame, narrow, rule);
751
+ const home = terminal.color ? "\x1b[H" : "\x1b[2J\x1b[H";
752
+ const clearLine = terminal.color ? "\x1b[K" : "";
753
+ const clearBelow = terminal.color ? "\x1b[J" : "";
754
+ return `${home}${lines
755
+ .map((line) => `${line}${clearLine}`)
756
+ .join("\n")}${clearBelow}\n`;
757
+ }
758
+ /** Richest first: the last candidate that still fits the window wins. */
759
+ const DENSITY_LADDER = [
760
+ { banner: 2, trail: true, hints: true, details: true },
761
+ { banner: 1, trail: true, hints: true, details: true },
762
+ { banner: 1, trail: false, hints: true, details: true },
763
+ { banner: 1, trail: false, hints: false, details: true },
764
+ { banner: 0, trail: false, hints: false, details: true },
765
+ { banner: 0, trail: false, hints: false, details: false },
766
+ ];
767
+ function fitWizard(view, terminal, frame, narrow, rule) {
768
+ const widest = terminal.columns >= BOLD_WIDTH + 2
769
+ ? 2
770
+ : terminal.columns >= COMPACT_WIDTH + 2
771
+ ? 1
772
+ : 0;
773
+ const limit = terminal.rows == null ? undefined : terminal.rows - 1;
774
+ let lines = [];
775
+ for (const candidate of DENSITY_LADDER) {
776
+ lines = composeWizard(view, terminal, frame, narrow, rule, {
777
+ banner: Math.min(candidate.banner, widest),
778
+ trail: candidate.trail && !narrow,
779
+ hints: candidate.hints && !narrow,
780
+ details: candidate.details,
781
+ });
782
+ if (limit == null || lines.length <= limit)
783
+ break;
784
+ }
785
+ return lines;
786
+ }
787
+ function composeWizard(view, terminal, frame, narrow, rule, density) {
387
788
  const lines = [
388
- "\x1b[2J\x1b[H",
389
- ...wordmark.map((line, index) => paint(terminal.color, BANNER_COLORS[(index + bannerFrame) % BANNER_COLORS.length], line)),
789
+ ...bannerLines(terminal, frame, density.banner),
790
+ ink(terminal, "muted", density.banner > 0
791
+ ? t("wizard.tagline.setup")
792
+ : t("wizard.tagline")),
390
793
  "",
391
794
  ];
392
795
  if (view.progress) {
393
- lines.push(`Stap ${view.progress.current}/${view.progress.total} · ${view.title}`, "");
796
+ const step = t("wizard.progress.step", {
797
+ current: view.progress.current,
798
+ total: view.progress.total,
799
+ });
800
+ lines.push(narrow
801
+ ? `${ink(terminal, "muted", step)} · ${strong(terminal, view.title)}`
802
+ : `${stepRail(terminal, view.progress.current, view.progress.total, frame)} ${ink(terminal, "muted", step)} ${strong(terminal, view.title)}`, "");
394
803
  }
395
804
  else {
396
- lines.push(view.title, "");
805
+ lines.push(`${screenGlyph(terminal, view.screen, frame)} ${strong(terminal, view.title)}`, "");
806
+ }
807
+ if (density.trail && view.completed && view.completed.length > 0) {
808
+ const column = labelColumn(view.completed);
809
+ for (const [label, value] of view.completed) {
810
+ lines.push(`${ink(terminal, "success", "✓")} ${ink(terminal, "muted", label.padEnd(column))} ${value}`);
811
+ }
812
+ lines.push("");
397
813
  }
398
814
  if (view.activeLabel) {
815
+ lines.push(ink(terminal, "accent", view.activeLabel, true));
399
816
  if (view.choices) {
400
- lines.push(paint(terminal.color, "1;36", view.activeLabel));
401
- for (const choice of view.choices) {
402
- const selected = choice.value === view.selectedChoice;
403
- lines.push(paint(terminal.color && selected, "1;96", `${selected ? ">" : " "} [${selected ? "x" : " "}] ${choice.label}`));
404
- if (!narrow && choice.hint)
405
- lines.push(` ${choice.hint}`);
406
- }
817
+ lines.push(...choiceLines(terminal, view.choices, view.selectedChoice, density.hints));
407
818
  }
408
819
  else {
409
- lines.push(paint(terminal.color, "1;36", view.activeLabel), `${paint(terminal.color, "36", "│")} ${paint(terminal.color, view.valueIsDefault ? "2" : "1", view.value || "_")}${paint(terminal.color, "96", "▌")}${view.valueIsDefault
410
- ? ` ${paint(terminal.color, "2", "(standaard)")}`
820
+ lines.push(`${ink(terminal, "accent", "│")} ${ink(terminal, view.valueIsDefault ? "muted" : "glow", view.value || "_", !view.valueIsDefault)}${ink(terminal, "glow", caretGlyph(frame))}${view.valueIsDefault
821
+ ? ` ${ink(terminal, "muted", t("wizard.value.default"))}`
411
822
  : ""}`);
823
+ if (view.valuePreview) {
824
+ lines.push(`${ink(terminal, "muted", "└")} ${ink(terminal, "muted", view.valuePreview)}`);
825
+ }
412
826
  }
413
- if (view.selectedDetails) {
827
+ if (density.details && view.selectedDetails) {
414
828
  lines.push("", ...renderChoiceDetails(view.selectedDetails, terminal));
415
829
  }
416
- if (view.validation)
417
- lines.push("", `! ${view.validation}`);
830
+ if (view.validation) {
831
+ lines.push("", ink(terminal, "warn", `⚠ ${view.validation}`));
832
+ }
418
833
  }
419
834
  if (view.review) {
420
- for (const [label, value] of view.review)
421
- lines.push(`${label}: ${value}`);
835
+ const column = labelColumn(view.review);
836
+ if (view.screen === "confirm") {
837
+ for (const [label, value] of view.review) {
838
+ lines.push(` ${ink(terminal, "muted", label.padEnd(column))} ${ink(terminal, "muted", value)}`);
839
+ }
840
+ lines.push("");
841
+ }
842
+ else {
843
+ const rows = view.review.map(([label, value]) => ({
844
+ text: `${label.padEnd(column)} ${value}`,
845
+ rendered: `${ink(terminal, "muted", label.padEnd(column))} ${strong(terminal, value)}`,
846
+ }));
847
+ const width = Math.max(24, Math.min(Math.max(20, terminal.columns - 4), rows.reduce((widest, row) => Math.max(widest, row.text.length), 0)));
848
+ lines.push(...cardLines(terminal, t("wizard.card.summary"), rows, width));
849
+ }
422
850
  }
423
851
  if (view.choices && !view.activeLabel) {
424
- for (const choice of view.choices) {
425
- const selected = choice.value === view.selectedChoice;
426
- lines.push(`${selected ? ">" : " "} [${selected ? "x" : " "}] ${choice.label}`);
852
+ lines.push(...choiceLines(terminal, view.choices, view.selectedChoice, false));
853
+ }
854
+ if (view.screen === "executing") {
855
+ lines.push(progressBar(terminal, frame, narrow ? 12 : 24), "", ink(terminal, "muted", t("wizard.executing.detail")));
856
+ }
857
+ if (view.createdPath) {
858
+ lines.push(`${ink(terminal, "muted", t("wizard.created.at"))} ${view.createdPath}`);
859
+ }
860
+ if (view.nextCommand) {
861
+ lines.push("", ink(terminal, "muted", t("wizard.next.command")), ` ${ink(terminal, "glow", view.nextCommand, true)}`);
862
+ }
863
+ if (view.screen === "success") {
864
+ lines.push("", `${ink(terminal, "success", SPARKLES[frame % SPARKLES.length])} ${ink(terminal, "muted", t("wizard.docs.line"))}`);
865
+ }
866
+ if (view.error) {
867
+ for (const line of view.error.split("\n")) {
868
+ lines.push(ink(terminal, "danger", line));
427
869
  }
428
870
  }
429
- if (view.createdPath)
430
- lines.push(`Gemaakt in: ${view.createdPath}`);
431
- if (view.nextCommand)
432
- lines.push("", "Volgende opdracht:", ` ${view.nextCommand}`);
433
- if (view.error)
434
- lines.push(view.error);
435
- if (view.controls.length)
436
- lines.push("", view.controls.join(" · "));
437
- return `${lines.join("\n")}\n`;
871
+ if (view.controls.length) {
872
+ lines.push("", ink(terminal, "muted", "─".repeat(rule)), ...controlLines(terminal, view.controls, Math.max(rule, terminal.columns - 1)));
873
+ }
874
+ return lines;
438
875
  }
439
876
  export function createWizardTerminal(input, output, env) {
440
877
  const wasRaw = input.isRaw === true;
441
878
  const wasFlowing = input.readableFlowing === true;
879
+ const buffered = [];
880
+ let waiting;
881
+ const onKeypress = (_text, key) => {
882
+ if (waiting) {
883
+ const resolve = waiting;
884
+ waiting = undefined;
885
+ resolve(key);
886
+ return;
887
+ }
888
+ buffered.push(key);
889
+ };
442
890
  return {
443
- columns: output.columns || 80,
891
+ get columns() {
892
+ return output.columns || 80;
893
+ },
894
+ get rows() {
895
+ return output.rows || 24;
896
+ },
444
897
  color: env.NO_COLOR == null && output.hasColors?.() !== false,
898
+ trueColor: env.NO_COLOR == null &&
899
+ (env.COLORTERM === "truecolor" ||
900
+ env.COLORTERM === "24bit" ||
901
+ output.hasColors?.(16_777_216) === true),
445
902
  hyperlinks: env.TERM !== "dumb" && env.NO_HYPERLINKS == null,
446
903
  write(value) {
447
904
  output.write(value);
@@ -449,16 +906,50 @@ export function createWizardTerminal(input, output, env) {
449
906
  enter() {
450
907
  emitKeypressEvents(input);
451
908
  input.setRawMode?.(true);
909
+ input.on("keypress", onKeypress);
452
910
  input.resume();
453
911
  output.write("\x1b[?25l");
454
912
  },
913
+ hasPendingKey() {
914
+ return buffered.length > 0;
915
+ },
455
916
  async readKey() {
917
+ const next = buffered.shift();
918
+ if (next)
919
+ return next;
920
+ return await new Promise((resolve) => {
921
+ waiting = resolve;
922
+ });
923
+ },
924
+ async readKeyWithin(milliseconds) {
925
+ const next = buffered.shift();
926
+ if (next)
927
+ return next;
456
928
  return await new Promise((resolve) => {
457
- input.once("keypress", (_text, key) => resolve(key));
929
+ let settled = false;
930
+ const timer = setTimeout(() => {
931
+ if (settled)
932
+ return;
933
+ settled = true;
934
+ if (waiting === listener)
935
+ waiting = undefined;
936
+ resolve(undefined);
937
+ }, milliseconds);
938
+ timer.unref?.();
939
+ const listener = (key) => {
940
+ if (settled)
941
+ return;
942
+ settled = true;
943
+ clearTimeout(timer);
944
+ resolve(key);
945
+ };
946
+ waiting = listener;
458
947
  });
459
948
  },
460
949
  openUrl: openExternalUrl,
461
950
  restore() {
951
+ input.off("keypress", onKeypress);
952
+ waiting = undefined;
462
953
  if (!wasRaw)
463
954
  input.setRawMode?.(false);
464
955
  if (!wasFlowing)