@remotedraw/cli 0.1.2 → 0.1.3

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