drexler 0.2.12 → 0.2.14

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