drexler 0.2.13 → 0.2.15

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,11 +1,12 @@
1
1
  import { Box, Text, useApp, useInput, useStdout } from "ink";
2
- import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
3
3
  import { STARTUP_TIPS } from "../startupTips.ts";
4
4
  import {
5
5
  MascotFrame,
6
6
  MASCOT_WIDTH,
7
7
  type MascotState,
8
8
  } from "./MascotFrame.tsx";
9
+ import { displayWidth, fitDisplayText } from "./graphemes.ts";
9
10
  import { useTheme } from "./ThemeContext.tsx";
10
11
 
11
12
  interface IntroFrame extends MascotState {
@@ -29,7 +30,7 @@ export const INTRO_BOOT_NOTES = [
29
30
 
30
31
  type IntroBootNote = (typeof INTRO_BOOT_NOTES)[number];
31
32
 
32
- const FRAMES: IntroFrame[] = [
33
+ const INTRO_FRAMES: IntroFrame[] = [
33
34
  {
34
35
  walls: "dim",
35
36
  brows: "hidden",
@@ -122,13 +123,131 @@ const FRAMES: IntroFrame[] = [
122
123
  },
123
124
  ];
124
125
 
125
- const COMPACT_NOTES = ["Booting", "Scanning", "Online"];
126
- const COMPACT_DELAY_MS = 850;
126
+ const COMPACT_INTRO_NOTES = ["Booting", "Scanning", "Online"];
127
+ const COMPACT_INTRO_DELAY_MS = 850;
127
128
  const SETTLE_HOLD_MS = 1200;
128
129
  const FRAME_CHROME_WIDTH = 4;
129
130
  const GUTTER_WIDTH = 4;
131
+ const SPLIT_DIVIDER_WIDTH = 3;
132
+ const SPLIT_DIVIDER_HEIGHT = 10;
133
+ const SPLIT_DIVIDER_ROWS: number[] = Array.from(
134
+ { length: SPLIT_DIVIDER_HEIGHT },
135
+ (_, i) => i,
136
+ );
130
137
  const BOOT_BAR_WIDTH = MASCOT_WIDTH - 1;
131
- const RIGHT_COLUMN_BORDER_WIDTH = 2;
138
+
139
+ // Width breakpoints (terminal columns).
140
+ const TINY_BREAKPOINT = 21;
141
+ const NARROW_BREAKPOINT = 24;
142
+ const COMPACT_BREAKPOINT = 72;
143
+ const WIDE_BREAKPOINT = 112;
144
+
145
+ // Inner-panel sizing floors / glue.
146
+ const MIN_DASHBOARD_WIDTH = 28;
147
+ const MIN_INNER_WIDTH = 24;
148
+ const MIN_COPY_WIDTH = 18;
149
+ const MIN_RIGHT_COLUMN_WIDTH = 20;
150
+ const MIN_MOOD_PANEL_WIDTH = 18;
151
+ const MAX_MOOD_PANEL_WIDTH = 44;
152
+ const RIGHT_COLUMN_INSET = 1;
153
+ const RIGHT_COLUMN_PAD_RIGHT = 1;
154
+ const LEFT_PANEL_MIN_COPY = 24;
155
+
156
+ export type MascotLayoutMode = "tiny" | "compact" | "stacked" | "split";
157
+
158
+ export interface MascotPanelBox {
159
+ width: number;
160
+ inset: number;
161
+ }
162
+
163
+ export interface MascotLayout {
164
+ mode: MascotLayoutMode;
165
+ available: number;
166
+ innerWidth: number;
167
+ leftPanel: MascotPanelBox;
168
+ rightColumn: MascotPanelBox;
169
+ rightChildWidth: number;
170
+ copy: MascotPanelBox;
171
+ mood: MascotPanelBox;
172
+ tips: MascotPanelBox;
173
+ dealDesk: MascotPanelBox;
174
+ }
175
+
176
+ export function computeMascotLayout(width: number): MascotLayout {
177
+ const safeWidth = Math.max(1, Math.floor(width));
178
+ if (safeWidth < TINY_BREAKPOINT) {
179
+ const w = safeWidth;
180
+ return {
181
+ mode: "tiny",
182
+ available: w,
183
+ innerWidth: w,
184
+ leftPanel: { width: w, inset: 0 },
185
+ rightColumn: { width: 0, inset: 0 },
186
+ rightChildWidth: 0,
187
+ copy: { width: w, inset: 0 },
188
+ mood: { width: w, inset: 0 },
189
+ tips: { width: w, inset: 0 },
190
+ dealDesk: { width: w, inset: 0 },
191
+ };
192
+ }
193
+ if (safeWidth < COMPACT_BREAKPOINT) {
194
+ const w = Math.max(1, safeWidth - 1);
195
+ return {
196
+ mode: "compact",
197
+ available: w,
198
+ innerWidth: w,
199
+ leftPanel: { width: w, inset: 1 },
200
+ rightColumn: { width: 0, inset: 0 },
201
+ rightChildWidth: 0,
202
+ copy: { width: w, inset: 0 },
203
+ mood: { width: w, inset: 0 },
204
+ tips: { width: w, inset: 0 },
205
+ dealDesk: { width: w, inset: 0 },
206
+ };
207
+ }
208
+ const available = Math.max(MIN_DASHBOARD_WIDTH, safeWidth);
209
+ const innerWidth = Math.max(MIN_INNER_WIDTH, available - FRAME_CHROME_WIDTH);
210
+ if (safeWidth < WIDE_BREAKPOINT) {
211
+ return {
212
+ mode: "stacked",
213
+ available,
214
+ innerWidth,
215
+ leftPanel: { width: innerWidth, inset: 0 },
216
+ rightColumn: { width: 0, inset: 0 },
217
+ rightChildWidth: innerWidth,
218
+ copy: { width: innerWidth, inset: 0 },
219
+ mood: { width: innerWidth, inset: 0 },
220
+ tips: { width: innerWidth, inset: 0 },
221
+ dealDesk: { width: innerWidth, inset: 0 },
222
+ };
223
+ }
224
+ const leftPanelWidth = Math.max(
225
+ MASCOT_WIDTH + GUTTER_WIDTH + LEFT_PANEL_MIN_COPY,
226
+ Math.floor((innerWidth - SPLIT_DIVIDER_WIDTH) / 2),
227
+ );
228
+ const rightColumnWidth = Math.max(
229
+ MIN_RIGHT_COLUMN_WIDTH,
230
+ innerWidth - leftPanelWidth - SPLIT_DIVIDER_WIDTH,
231
+ );
232
+ const rightInner = Math.max(1, rightColumnWidth - RIGHT_COLUMN_PAD_RIGHT);
233
+ const rightChildWidth = Math.max(1, rightInner - RIGHT_COLUMN_INSET);
234
+ const copyWidth = Math.max(
235
+ MIN_COPY_WIDTH,
236
+ leftPanelWidth - MASCOT_WIDTH - GUTTER_WIDTH - 1,
237
+ );
238
+ return {
239
+ mode: "split",
240
+ available,
241
+ innerWidth,
242
+ leftPanel: { width: leftPanelWidth, inset: 0 },
243
+ rightColumn: { width: rightColumnWidth, inset: 0 },
244
+ rightChildWidth,
245
+ copy: { width: copyWidth, inset: 0 },
246
+ mood: { width: copyWidth, inset: 0 },
247
+ tips: { width: rightChildWidth, inset: RIGHT_COLUMN_INSET },
248
+ dealDesk: { width: rightChildWidth, inset: RIGHT_COLUMN_INSET },
249
+ };
250
+ }
132
251
 
133
252
  interface IntroProps {
134
253
  greeting: string;
@@ -137,6 +256,8 @@ interface IntroProps {
137
256
  interface MascotDashboardProps {
138
257
  greeting: string;
139
258
  width: number;
259
+ mood?: string;
260
+ bootProgress?: number;
140
261
  state?: MascotState;
141
262
  bar?: string;
142
263
  barColor?: string;
@@ -144,7 +265,7 @@ interface MascotDashboardProps {
144
265
  dealDesk?: (width: number) => ReactNode;
145
266
  }
146
267
 
147
- function bootBar(frameIdx: number, total: number): string {
268
+ function introBootBar(frameIdx: number, total: number): string {
148
269
  const active = Math.max(
149
270
  1,
150
271
  Math.ceil(((frameIdx + 1) / total) * BOOT_BAR_WIDTH),
@@ -152,22 +273,509 @@ function bootBar(frameIdx: number, total: number): string {
152
273
  return " " + "▰".repeat(active) + "▱".repeat(BOOT_BAR_WIDTH - active);
153
274
  }
154
275
 
276
+ function introBootProgress(frameIdx: number, total: number): number {
277
+ return Math.max(0, Math.min(1, (frameIdx + 1) / total));
278
+ }
279
+
280
+ function gaugeBar(progress: number, width: number): string {
281
+ const safeWidth = Math.max(1, width);
282
+ const filled = Math.max(
283
+ 0,
284
+ Math.min(safeWidth, Math.round(progress * safeWidth)),
285
+ );
286
+ return `${"█".repeat(filled)}${"░".repeat(safeWidth - filled)}`;
287
+ }
288
+
289
+ function titledPanelBottom(width: number): string {
290
+ return `╰${"─".repeat(Math.max(0, width - 2))}╯`;
291
+ }
292
+
293
+ function fixedDisplayRows(
294
+ input: string,
295
+ width: number,
296
+ rowCount: number,
297
+ ): string[] {
298
+ const safeWidth = Math.max(1, width);
299
+ const rows: string[] = [];
300
+ const words = input.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
301
+ let cursor = 0;
302
+
303
+ while (rows.length < rowCount && cursor < words.length) {
304
+ const remaining = words.slice(cursor).join(" ");
305
+ if (rows.length === rowCount - 1 || displayWidth(remaining) <= safeWidth) {
306
+ rows.push(fitDisplayText(remaining, safeWidth));
307
+ cursor = words.length;
308
+ break;
309
+ }
310
+
311
+ let row = words[cursor] ?? "";
312
+ cursor += 1;
313
+ if (displayWidth(row) > safeWidth) {
314
+ rows.push(fitDisplayText(row, safeWidth));
315
+ continue;
316
+ }
317
+
318
+ while (cursor < words.length) {
319
+ const next = words[cursor] ?? "";
320
+ const candidate = `${row} ${next}`;
321
+ if (displayWidth(candidate) > safeWidth) break;
322
+ row = candidate;
323
+ cursor += 1;
324
+ }
325
+ rows.push(row);
326
+ }
327
+
328
+ while (rows.length < rowCount) rows.push("");
329
+ return rows;
330
+ }
331
+
332
+ type IntroColorPhase = "early" | "middle" | "late";
333
+
334
+ function introTotalFrames(width: number): number {
335
+ return width < COMPACT_BREAKPOINT
336
+ ? COMPACT_INTRO_NOTES.length
337
+ : INTRO_FRAMES.length;
338
+ }
339
+
340
+ function introFrameDelayMs(frameIdx: number, width: number): number {
341
+ if (width < COMPACT_BREAKPOINT) return COMPACT_INTRO_DELAY_MS;
342
+ return (
343
+ INTRO_FRAMES[frameIdx] ?? INTRO_FRAMES[INTRO_FRAMES.length - 1]!
344
+ ).delayMs;
345
+ }
346
+
347
+ function introColorPhase(frameIdx: number, total: number): IntroColorPhase {
348
+ if (frameIdx < total / 3) return "early";
349
+ if (frameIdx < (total * 2) / 3) return "middle";
350
+ return "late";
351
+ }
352
+
353
+ export function introPhaseColor(
354
+ phase: IntroColorPhase,
355
+ colors: { error: string; warning: string; primaryLight: string },
356
+ ): string {
357
+ return phase === "early"
358
+ ? colors.error
359
+ : phase === "middle"
360
+ ? colors.warning
361
+ : colors.primaryLight;
362
+ }
363
+
364
+ function introSnapshot(frameIdx: number, width: number) {
365
+ const compact = width < COMPACT_BREAKPOINT;
366
+ const total = introTotalFrames(width);
367
+ const boundedFrameIdx = Math.min(frameIdx, total - 1);
368
+ const state =
369
+ INTRO_FRAMES[boundedFrameIdx] ?? INTRO_FRAMES[INTRO_FRAMES.length - 1]!;
370
+ const note = compact
371
+ ? COMPACT_INTRO_NOTES[
372
+ Math.min(boundedFrameIdx, COMPACT_INTRO_NOTES.length - 1)
373
+ ]!
374
+ : state.note;
375
+ return {
376
+ bar: introBootBar(boundedFrameIdx, total),
377
+ colorPhase: introColorPhase(frameIdx, total),
378
+ frameIdx: boundedFrameIdx,
379
+ note,
380
+ progress: introBootProgress(boundedFrameIdx, total),
381
+ state,
382
+ status: `${INTRO_STATUS_PREFIX}${note}`,
383
+ total,
384
+ };
385
+ }
386
+
387
+ export function useIntroAnimation(
388
+ width: number,
389
+ active: boolean,
390
+ onComplete?: () => void,
391
+ ) {
392
+ const [frameIdx, setFrameIdx] = useState(0);
393
+
394
+ useEffect(() => {
395
+ if (!active) {
396
+ setFrameIdx(0);
397
+ return;
398
+ }
399
+
400
+ const total = introTotalFrames(width);
401
+ if (frameIdx >= total - 1) {
402
+ if (!onComplete) return;
403
+ const handle = setTimeout(onComplete, SETTLE_HOLD_MS);
404
+ return () => clearTimeout(handle);
405
+ }
406
+
407
+ const handle = setTimeout(() => {
408
+ setFrameIdx((idx) => Math.min(idx + 1, total - 1));
409
+ }, introFrameDelayMs(frameIdx, width));
410
+ return () => clearTimeout(handle);
411
+ }, [active, frameIdx, onComplete, width]);
412
+
413
+ return useMemo(() => introSnapshot(frameIdx, width), [frameIdx, width]);
414
+ }
415
+
155
416
  function TipsPanel({ width }: { width: number }) {
156
417
  const t = useTheme();
157
418
  const textWidth = Math.max(1, width);
419
+ const innerWidth = Math.max(1, textWidth - 4);
420
+ const title = "Tips";
421
+ const titlePrefix = "╭─ ";
422
+ const titleSuffix = " ";
423
+ const titleRule = "─".repeat(
424
+ Math.max(
425
+ 0,
426
+ textWidth -
427
+ displayWidth(titlePrefix) -
428
+ displayWidth(title) -
429
+ displayWidth(titleSuffix) -
430
+ displayWidth("╮"),
431
+ ),
432
+ );
158
433
  return (
159
434
  <Box flexDirection="column" width={textWidth}>
160
- <Text bold color={t.primaryLight}>
161
- Tips for getting started
435
+ <Text color={t.primary}>
436
+ {titlePrefix}
437
+ <Text bold color={t.primaryLight}>{title}</Text>
438
+ {titleSuffix}
439
+ {titleRule}
440
+
162
441
  </Text>
163
- <Box flexDirection="column" paddingLeft={2}>
164
- {STARTUP_TIPS.map((tip, idx) => (
165
- <Text key={tip} color={t.dim}>
166
- <Text color={t.primary}>{idx + 1}. </Text>
167
- {tip}
442
+ {STARTUP_TIPS.map((tip, idx) => {
443
+ const label = `${idx + 1}. `;
444
+ const tipWidth = Math.max(1, innerWidth - displayWidth(label));
445
+ const clippedTip = fitDisplayText(tip, tipWidth);
446
+ const content = `${label}${clippedTip}`;
447
+ return (
448
+ <Text key={tip}>
449
+ <Text color={t.primary}>│ </Text>
450
+ <Text color={t.primaryLight}>{label}</Text>
451
+ <Text color={t.dim}>{clippedTip}</Text>
452
+ <Text color={t.primary}>
453
+ {" ".repeat(Math.max(0, innerWidth - displayWidth(content)))} │
454
+ </Text>
168
455
  </Text>
169
- ))}
456
+ );
457
+ })}
458
+ <Text color={t.primary}>{titledPanelBottom(textWidth)}</Text>
459
+ </Box>
460
+ );
461
+ }
462
+
463
+ type MoodTone = "error" | "primaryLight" | "warning";
464
+
465
+ interface MoodPosture {
466
+ badge: string;
467
+ detail: string;
468
+ tone: MoodTone;
469
+ }
470
+
471
+ const NAMED_MOOD_POSTURES: Record<string, readonly MoodPosture[]> = {
472
+ angry: [
473
+ {
474
+ badge: "HOSTILE TENDER",
475
+ detail: "board patience: vaporized",
476
+ tone: "error",
477
+ },
478
+ {
479
+ badge: "REDLINE FEVER",
480
+ detail: "counsel posture: braced",
481
+ tone: "error",
482
+ },
483
+ {
484
+ badge: "FEE HAWK",
485
+ detail: "intern confidence: first pass only",
486
+ tone: "warning",
487
+ },
488
+ ],
489
+ exhausted: [
490
+ {
491
+ badge: "COFFEE DEBT",
492
+ detail: "intern confidence: ceremonial",
493
+ tone: "warning",
494
+ },
495
+ {
496
+ badge: "QUORUM NAPPING",
497
+ detail: "board patience: on fumes",
498
+ tone: "primaryLight",
499
+ },
500
+ {
501
+ badge: "LATE CLOSE",
502
+ detail: "risk posture: blinking slowly",
503
+ tone: "warning",
504
+ },
505
+ ],
506
+ generous: [
507
+ {
508
+ badge: "FEE HOLIDAY",
509
+ detail: "board patience: briefly subsidized",
510
+ tone: "primaryLight",
511
+ },
512
+ {
513
+ badge: "SOFT CLOSE",
514
+ detail: "risk posture: laminated optimism",
515
+ tone: "primaryLight",
516
+ },
517
+ {
518
+ badge: "COUNSEL NEAR",
519
+ detail: "intern confidence: first pass only",
520
+ tone: "error",
521
+ },
522
+ ],
523
+ manic: [
524
+ {
525
+ badge: "DEAL SPIRAL",
526
+ detail: "committee pulse: overclocked",
527
+ tone: "warning",
528
+ },
529
+ {
530
+ badge: "CALENDAR HEAT",
531
+ detail: "board patience: rescheduled twice",
532
+ tone: "warning",
533
+ },
534
+ {
535
+ badge: "TERM SHEET TORNADO",
536
+ detail: "risk posture: wearing a helmet",
537
+ tone: "error",
538
+ },
539
+ ],
540
+ paranoid: [
541
+ {
542
+ badge: "RISK BUNKER",
543
+ detail: "board patience: subpoena-ready",
544
+ tone: "error",
545
+ },
546
+ {
547
+ badge: "BURNER ROOM",
548
+ detail: "counsel posture: whispering",
549
+ tone: "warning",
550
+ },
551
+ {
552
+ badge: "BOARD LOCKED",
553
+ detail: "committee pulse: encrypted",
554
+ tone: "error",
555
+ },
556
+ ],
557
+ ruthless: [
558
+ {
559
+ badge: "FEE HAWK",
560
+ detail: "risk posture: smiling through counsel",
561
+ tone: "primaryLight",
562
+ },
563
+ {
564
+ badge: "MANDATE CLAW",
565
+ detail: "board patience: non-appealable",
566
+ tone: "warning",
567
+ },
568
+ {
569
+ badge: "COVENANT TEETH",
570
+ detail: "intern confidence: collateralized",
571
+ tone: "error",
572
+ },
573
+ ],
574
+ victorious: [
575
+ {
576
+ badge: "TAPE PARADE",
577
+ detail: "intern confidence: legally inadvisable",
578
+ tone: "warning",
579
+ },
580
+ {
581
+ badge: "BELL RUNG",
582
+ detail: "board patience: temporarily restored",
583
+ tone: "primaryLight",
584
+ },
585
+ {
586
+ badge: "TROPHY FILING",
587
+ detail: "counsel posture: drafting confetti",
588
+ tone: "warning",
589
+ },
590
+ ],
591
+ };
592
+
593
+ const FALLBACK_MOOD_POSTURES: readonly MoodPosture[] = [
594
+ {
595
+ badge: "CALENDAR HEAT",
596
+ detail: "board patience: rescheduled twice",
597
+ tone: "warning",
598
+ },
599
+ {
600
+ badge: "SOFT CLOSE",
601
+ detail: "risk posture: laminated optimism",
602
+ tone: "primaryLight",
603
+ },
604
+ {
605
+ badge: "COUNSEL NEAR",
606
+ detail: "intern confidence: first pass only",
607
+ tone: "error",
608
+ },
609
+ ];
610
+
611
+ function hashText(input: string): number {
612
+ return Array.from(input).reduce(
613
+ (sum, char) => sum + (char.codePointAt(0) ?? 0),
614
+ 0,
615
+ );
616
+ }
617
+
618
+ function moodPosture(mood: string, seed: number): MoodPosture {
619
+ const key = mood.trim().toLowerCase();
620
+ const pool = NAMED_MOOD_POSTURES[key] ?? FALLBACK_MOOD_POSTURES;
621
+ return pool[Math.abs(hashText(key) + seed) % pool.length] ?? pool[0]!;
622
+ }
623
+
624
+ function moodToneColor(t: ReturnType<typeof useTheme>, tone: MoodTone): string {
625
+ return tone === "error"
626
+ ? t.error
627
+ : tone === "warning"
628
+ ? t.warning
629
+ : t.primaryLight;
630
+ }
631
+
632
+ function bootPostureDetail(progress: number): string {
633
+ if (progress < 0.25) return "committee pulse: suspicious";
634
+ if (progress < 0.5) return "fee antenna: extending";
635
+ if (progress < 0.75) return "counsel posture: stiffening";
636
+ return "board patience: nearly loaded";
637
+ }
638
+
639
+ function MoodReadout({
640
+ mood,
641
+ progress = 1,
642
+ progressColor,
643
+ width,
644
+ }: {
645
+ mood?: string;
646
+ progress?: number;
647
+ progressColor?: string;
648
+ width: number;
649
+ }) {
650
+ const t = useTheme();
651
+ if (!mood) return null;
652
+
653
+ const postureSeed = useMemo(() => Math.floor(Math.random() * 1_000_000_000), []);
654
+ const boundedProgress = Math.max(0, Math.min(1, progress));
655
+ const normalizedMood = mood.toUpperCase();
656
+ const posture = moodPosture(mood, postureSeed);
657
+ const postureColor = moodToneColor(t, posture.tone);
658
+ const moodPrefix = "";
659
+ const pct = `${Math.round(boundedProgress * 100)
660
+ .toString()
661
+ .padStart(3, " ")}%`;
662
+
663
+ if (width < NARROW_BREAKPOINT) {
664
+ const tinyText =
665
+ boundedProgress >= 1
666
+ ? `${normalizedMood} / ${posture.badge}`
667
+ : pct;
668
+ return (
669
+ <Box width={Math.max(1, width)}>
670
+ <Text
671
+ color={
672
+ boundedProgress >= 1 ? postureColor : progressColor ?? t.primaryLight
673
+ }
674
+ >
675
+ {fitDisplayText(tinyText, Math.max(1, width))}
676
+ </Text>
170
677
  </Box>
678
+ );
679
+ }
680
+
681
+ const panelWidth = Math.max(MIN_MOOD_PANEL_WIDTH, Math.min(MAX_MOOD_PANEL_WIDTH, width));
682
+ const innerWidth = Math.max(1, panelWidth - 4);
683
+ const title = "Mood";
684
+ const isSettled = boundedProgress >= 1;
685
+ const topPrefix = "╭─ ";
686
+ const topSuffix = " ";
687
+ const topRule = "─".repeat(
688
+ Math.max(
689
+ 0,
690
+ panelWidth -
691
+ displayWidth(topPrefix) -
692
+ displayWidth(title) -
693
+ displayWidth(topSuffix) -
694
+ displayWidth("╮"),
695
+ ),
696
+ );
697
+ const settledSuffix = ` / ${posture.badge}`;
698
+ const compactSettledSuffix = ` / ${fitDisplayText(posture.badge, 8)}`;
699
+ const activeSettledSuffix =
700
+ displayWidth(moodPrefix) +
701
+ displayWidth(normalizedMood) +
702
+ displayWidth(settledSuffix) <=
703
+ innerWidth
704
+ ? settledSuffix
705
+ : compactSettledSuffix;
706
+ const moodTextWidth = Math.max(
707
+ 1,
708
+ innerWidth -
709
+ displayWidth(moodPrefix) -
710
+ (isSettled ? displayWidth(activeSettledSuffix) : 0),
711
+ );
712
+ const moodText = fitDisplayText(normalizedMood, moodTextWidth);
713
+ const settledContent = `${moodPrefix}${moodText}${activeSettledSuffix}`;
714
+ const detailText = fitDisplayText(
715
+ isSettled ? posture.detail : bootPostureDetail(boundedProgress),
716
+ innerWidth,
717
+ );
718
+ const pctWidth = displayWidth(pct);
719
+ const barWidth = Math.max(4, innerWidth - pctWidth - 4);
720
+ const bar = gaugeBar(boundedProgress, barWidth);
721
+ const gaugeContent = `[${bar}] ${pct}`;
722
+
723
+ return (
724
+ <Box flexDirection="column" width={panelWidth}>
725
+ <Text color={t.primaryDim}>
726
+ {topPrefix}
727
+ <Text bold color={t.warning}>
728
+ {title}
729
+ </Text>
730
+ {topSuffix}
731
+ {topRule}
732
+
733
+ </Text>
734
+ {isSettled ? (
735
+ <>
736
+ <Text>
737
+ <Text color={t.primaryDim}>│ </Text>
738
+ <Text bold color={postureColor}>
739
+ {moodText}
740
+ </Text>
741
+ <Text color={t.primaryDim}>{activeSettledSuffix}</Text>
742
+ <Text color={t.primaryDim}>
743
+ {" ".repeat(
744
+ Math.max(0, innerWidth - displayWidth(settledContent)),
745
+ )}
746
+ {" │"}
747
+ </Text>
748
+ </Text>
749
+ <Text>
750
+ <Text color={t.primaryDim}>│ </Text>
751
+ <Text color={t.dim}>{detailText}</Text>
752
+ <Text color={t.primaryDim}>
753
+ {" ".repeat(Math.max(0, innerWidth - displayWidth(detailText)))} │
754
+ </Text>
755
+ </Text>
756
+ </>
757
+ ) : (
758
+ <>
759
+ <Text>
760
+ <Text color={t.primaryDim}>│ </Text>
761
+ <Text color={t.primaryDim}>[</Text>
762
+ <Text color={progressColor ?? t.primaryLight}>{bar}</Text>
763
+ <Text color={t.primaryDim}>] </Text>
764
+ <Text color={t.primaryLight}>{pct}</Text>
765
+ <Text color={t.primaryDim}>
766
+ {" ".repeat(Math.max(0, innerWidth - displayWidth(gaugeContent)))} │
767
+ </Text>
768
+ </Text>
769
+ <Text>
770
+ <Text color={t.primaryDim}>│ </Text>
771
+ <Text color={t.dim}>{detailText}</Text>
772
+ <Text color={t.primaryDim}>
773
+ {" ".repeat(Math.max(0, innerWidth - displayWidth(detailText)))} │
774
+ </Text>
775
+ </Text>
776
+ </>
777
+ )}
778
+ <Text color={t.primaryDim}>{titledPanelBottom(panelWidth)}</Text>
171
779
  </Box>
172
780
  );
173
781
  }
@@ -175,82 +783,87 @@ function TipsPanel({ width }: { width: number }) {
175
783
  export function MascotDashboard({
176
784
  greeting,
177
785
  width,
178
- state = FRAMES[FRAMES.length - 1]!,
179
- bar = bootBar(FRAMES.length - 1, FRAMES.length),
786
+ mood,
787
+ bootProgress = 1,
788
+ state = INTRO_FRAMES[INTRO_FRAMES.length - 1]!,
789
+ bar = introBootBar(INTRO_FRAMES.length - 1, INTRO_FRAMES.length),
180
790
  barColor,
181
791
  mascotStatus = `${INTRO_STATUS_PREFIX}${INTRO_BOOT_NOTES[INTRO_BOOT_NOTES.length - 1]}`,
182
792
  dealDesk,
183
793
  }: MascotDashboardProps) {
184
794
  const t = useTheme();
185
795
  const resolvedBarColor = barColor ?? t.primaryLight;
186
- const tinyTerminal = width < 21;
187
- const compact = width < 72;
188
- const sideBySide = width >= 112;
189
- const available = compact ? Math.max(1, width - 1) : Math.max(28, width - 1);
190
- const innerWidth = compact
191
- ? available
192
- : Math.max(24, available - FRAME_CHROME_WIDTH);
193
- const leftPanelWidth = compact
194
- ? available
195
- : sideBySide
196
- ? Math.max(
197
- MASCOT_WIDTH + GUTTER_WIDTH + 24,
198
- Math.floor((innerWidth - RIGHT_COLUMN_BORDER_WIDTH) / 2),
199
- )
200
- : innerWidth;
201
- const rightColumnWidth = sideBySide
202
- ? Math.max(20, innerWidth - leftPanelWidth)
203
- : innerWidth;
204
- const rightInnerWidth = sideBySide
205
- ? Math.max(1, rightColumnWidth - RIGHT_COLUMN_BORDER_WIDTH)
206
- : rightColumnWidth;
207
- const tipsWidth = sideBySide
208
- ? rightInnerWidth
209
- : innerWidth;
210
- const copyWidth = compact
211
- ? available
212
- : sideBySide
213
- ? Math.max(18, leftPanelWidth - MASCOT_WIDTH - GUTTER_WIDTH)
214
- : innerWidth;
215
-
216
- if (tinyTerminal) {
796
+ const layout = computeMascotLayout(width);
797
+ const sideBySide = layout.mode === "split";
798
+ const wideGreetingRows = sideBySide
799
+ ? fixedDisplayRows(greeting, layout.copy.width, 2)
800
+ : [];
801
+
802
+ if (layout.mode === "tiny") {
217
803
  return (
218
- <Box width={available} flexDirection="column">
804
+ <Box width={layout.available} flexDirection="column">
219
805
  <Text color={resolvedBarColor}>{mascotStatus}</Text>
220
806
  <Text bold color={t.primaryLight}>
221
807
  Drexler™
222
808
  </Text>
223
809
  <Text color={t.primaryLight}>{greeting}</Text>
224
- {dealDesk ? <Box marginTop={1}>{dealDesk(Math.max(1, available))}</Box> : null}
810
+ <Box marginTop={1}>
811
+ <MoodReadout
812
+ mood={mood}
813
+ progress={bootProgress}
814
+ progressColor={resolvedBarColor}
815
+ width={layout.mood.width}
816
+ />
817
+ </Box>
818
+ {dealDesk ? (
819
+ <Box marginTop={1}>{dealDesk(layout.dealDesk.width)}</Box>
820
+ ) : null}
225
821
  </Box>
226
822
  );
227
823
  }
228
824
 
229
- if (compact) {
825
+ if (layout.mode === "compact") {
230
826
  return (
231
- <Box marginLeft={1} width={available} flexDirection="column">
827
+ <Box
828
+ marginLeft={layout.leftPanel.inset}
829
+ width={layout.available}
830
+ flexDirection="column"
831
+ >
232
832
  <Text color={resolvedBarColor}>{bar}</Text>
233
833
  <Text color={resolvedBarColor}>{mascotStatus}</Text>
234
834
  <Text bold color={t.primaryLight}>
235
835
  Drexler International™
236
836
  </Text>
237
837
  <Text color={t.primaryLight}>{greeting}</Text>
238
- {dealDesk ? <Box marginTop={1}>{dealDesk(Math.max(1, available))}</Box> : null}
838
+ <Box marginTop={1}>
839
+ <MoodReadout
840
+ mood={mood}
841
+ progress={bootProgress}
842
+ progressColor={resolvedBarColor}
843
+ width={layout.mood.width}
844
+ />
845
+ </Box>
846
+ {dealDesk ? (
847
+ <Box marginTop={1}>{dealDesk(layout.dealDesk.width)}</Box>
848
+ ) : null}
239
849
  </Box>
240
850
  );
241
851
  }
242
852
 
243
853
  return (
244
- <Box width={available}>
854
+ <Box width={layout.available}>
245
855
  <Box
246
- width={available}
856
+ width={layout.available}
247
857
  borderStyle="round"
248
858
  borderColor={t.primary}
249
859
  paddingX={1}
250
860
  flexDirection={sideBySide ? "row" : "column"}
251
861
  alignItems={sideBySide ? "flex-start" : "center"}
252
862
  >
253
- <Box flexDirection={sideBySide ? "row" : "column"} width={leftPanelWidth}>
863
+ <Box
864
+ flexDirection={sideBySide ? "row" : "column"}
865
+ width={layout.leftPanel.width}
866
+ >
254
867
  <Box
255
868
  width={MASCOT_WIDTH}
256
869
  flexShrink={0}
@@ -264,32 +877,61 @@ export function MascotDashboard({
264
877
  <Box
265
878
  flexDirection="column"
266
879
  justifyContent="center"
267
- width={copyWidth}
880
+ width={layout.copy.width}
268
881
  marginTop={sideBySide ? 1 : 0}
269
882
  >
270
883
  <Text bold color={t.primaryLight}>
271
884
  Drexler International™
272
885
  </Text>
273
886
  <Box height={1} />
274
- <Text color={t.primaryLight}>{greeting}</Text>
887
+ {sideBySide ? (
888
+ wideGreetingRows.map((row, idx) => (
889
+ <Text key={idx} color={t.primaryLight}>
890
+ {row || " "}
891
+ </Text>
892
+ ))
893
+ ) : (
894
+ <Text color={t.primaryLight}>{greeting}</Text>
895
+ )}
275
896
  <Box height={1} />
897
+ <MoodReadout
898
+ mood={mood}
899
+ progress={bootProgress}
900
+ progressColor={resolvedBarColor}
901
+ width={layout.mood.width}
902
+ />
276
903
  </Box>
277
904
  </Box>
278
905
  {sideBySide ? (
906
+ <>
907
+ <Box flexDirection="column" width={SPLIT_DIVIDER_WIDTH} flexShrink={0}>
908
+ {SPLIT_DIVIDER_ROWS.map((idx) => (
909
+ <Text key={idx} color={t.primaryDim}>
910
+ {" │ "}
911
+ </Text>
912
+ ))}
913
+ </Box>
279
914
  <Box
280
915
  flexDirection="column"
281
- width={rightColumnWidth}
282
- borderLeft
283
- borderColor={t.primaryDim}
284
- paddingLeft={1}
916
+ width={layout.rightColumn.width}
917
+ paddingRight={RIGHT_COLUMN_PAD_RIGHT}
285
918
  >
286
- <TipsPanel width={rightInnerWidth} />
287
- {dealDesk ? <Box marginTop={1}>{dealDesk(rightInnerWidth)}</Box> : null}
919
+ <Box marginLeft={layout.tips.inset}>
920
+ <TipsPanel width={layout.tips.width} />
921
+ </Box>
922
+ {dealDesk ? (
923
+ <Box marginLeft={layout.dealDesk.inset}>
924
+ {dealDesk(layout.dealDesk.width)}
925
+ </Box>
926
+ ) : null}
288
927
  </Box>
928
+ </>
289
929
  ) : (
290
- <Box marginTop={1} width={tipsWidth} flexDirection="column">
291
- <TipsPanel width={tipsWidth} />
292
- {dealDesk ? <Box marginTop={1}>{dealDesk(tipsWidth)}</Box> : null}
930
+ <Box marginTop={1} width={layout.tips.width} flexDirection="column">
931
+ <TipsPanel width={layout.tips.width} />
932
+ {dealDesk ? (
933
+ <Box marginTop={1}>{dealDesk(layout.dealDesk.width)}</Box>
934
+ ) : null}
293
935
  </Box>
294
936
  )}
295
937
  </Box>
@@ -302,7 +944,7 @@ export function MascotIntro({ greeting }: IntroProps) {
302
944
  const { exit } = useApp();
303
945
  const { stdout } = useStdout();
304
946
  const [cols, setCols] = useState(stdout?.columns ?? 80);
305
- const [frameIdx, setFrameIdx] = useState(0);
947
+ const intro = useIntroAnimation(cols, true, exit);
306
948
 
307
949
  useEffect(() => {
308
950
  if (!stdout) return;
@@ -317,56 +959,16 @@ export function MascotIntro({ greeting }: IntroProps) {
317
959
  if (key.escape || key.return || (key.ctrl && _input === "c")) exit();
318
960
  });
319
961
 
320
- const mountedRef = useRef(true);
321
- useEffect(() => {
322
- return () => {
323
- mountedRef.current = false;
324
- };
325
- }, []);
326
-
327
- useEffect(() => {
328
- const compact = cols < 72;
329
- const total = compact ? COMPACT_NOTES.length : FRAMES.length;
330
- if (frameIdx >= total - 1) {
331
- const handle = setTimeout(() => {
332
- if (mountedRef.current) exit();
333
- }, SETTLE_HOLD_MS);
334
- return () => clearTimeout(handle);
335
- }
336
- const delay = compact
337
- ? COMPACT_DELAY_MS
338
- : (FRAMES[frameIdx] ?? FRAMES[FRAMES.length - 1]!).delayMs;
339
- const handle = setTimeout(() => {
340
- if (mountedRef.current) setFrameIdx((i) => i + 1);
341
- }, delay);
342
- return () => clearTimeout(handle);
343
- }, [cols, frameIdx, exit]);
344
-
345
- const state = FRAMES[frameIdx] ?? FRAMES[FRAMES.length - 1]!;
346
- const compact = cols < 72;
347
- const bar = bootBar(
348
- Math.min(frameIdx, compact ? COMPACT_NOTES.length - 1 : FRAMES.length - 1),
349
- compact ? COMPACT_NOTES.length : FRAMES.length,
350
- );
351
- const barColor =
352
- frameIdx < (compact ? COMPACT_NOTES.length : FRAMES.length) / 3
353
- ? t.error
354
- : frameIdx < ((compact ? COMPACT_NOTES.length : FRAMES.length) * 2) / 3
355
- ? t.warning
356
- : t.primaryLight;
357
- const note = compact
358
- ? COMPACT_NOTES[Math.min(frameIdx, COMPACT_NOTES.length - 1)]!
359
- : state.note;
360
- const mascotStatus = `${INTRO_STATUS_PREFIX}${note}`;
962
+ const barColor = introPhaseColor(intro.colorPhase, t);
361
963
 
362
964
  return (
363
965
  <MascotDashboard
364
966
  greeting={greeting}
365
967
  width={cols}
366
- state={state}
367
- bar={bar}
968
+ state={intro.state}
969
+ bar={intro.bar}
368
970
  barColor={barColor}
369
- mascotStatus={mascotStatus}
971
+ mascotStatus={intro.status}
370
972
  />
371
973
  );
372
974
  }