drexler 0.2.13 → 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,13 +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;
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;
132
138
 
133
139
  interface IntroProps {
134
140
  greeting: string;
@@ -137,6 +143,8 @@ interface IntroProps {
137
143
  interface MascotDashboardProps {
138
144
  greeting: string;
139
145
  width: number;
146
+ mood?: string;
147
+ bootProgress?: number;
140
148
  state?: MascotState;
141
149
  bar?: string;
142
150
  barColor?: string;
@@ -144,7 +152,7 @@ interface MascotDashboardProps {
144
152
  dealDesk?: (width: number) => ReactNode;
145
153
  }
146
154
 
147
- function bootBar(frameIdx: number, total: number): string {
155
+ function introBootBar(frameIdx: number, total: number): string {
148
156
  const active = Math.max(
149
157
  1,
150
158
  Math.ceil(((frameIdx + 1) / total) * BOOT_BAR_WIDTH),
@@ -152,22 +160,507 @@ function bootBar(frameIdx: number, total: number): string {
152
160
  return " " + "▰".repeat(active) + "▱".repeat(BOOT_BAR_WIDTH - active);
153
161
  }
154
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
+
155
301
  function TipsPanel({ width }: { width: number }) {
156
302
  const t = useTheme();
157
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
+ );
158
318
  return (
159
319
  <Box flexDirection="column" width={textWidth}>
160
- <Text bold color={t.primaryLight}>
161
- Tips for getting started
320
+ <Text color={t.primary}>
321
+ {titlePrefix}
322
+ <Text bold color={t.primaryLight}>{title}</Text>
323
+ {titleSuffix}
324
+ {titleRule}
325
+
162
326
  </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}
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>
168
340
  </Text>
169
- ))}
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>
170
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>
171
664
  </Box>
172
665
  );
173
666
  }
@@ -175,8 +668,10 @@ function TipsPanel({ width }: { width: number }) {
175
668
  export function MascotDashboard({
176
669
  greeting,
177
670
  width,
178
- state = FRAMES[FRAMES.length - 1]!,
179
- 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),
180
675
  barColor,
181
676
  mascotStatus = `${INTRO_STATUS_PREFIX}${INTRO_BOOT_NOTES[INTRO_BOOT_NOTES.length - 1]}`,
182
677
  dealDesk,
@@ -186,7 +681,7 @@ export function MascotDashboard({
186
681
  const tinyTerminal = width < 21;
187
682
  const compact = width < 72;
188
683
  const sideBySide = width >= 112;
189
- const available = compact ? Math.max(1, width - 1) : Math.max(28, width - 1);
684
+ const available = compact ? Math.max(1, width - 1) : Math.max(28, width);
190
685
  const innerWidth = compact
191
686
  ? available
192
687
  : Math.max(24, available - FRAME_CHROME_WIDTH);
@@ -195,14 +690,14 @@ export function MascotDashboard({
195
690
  : sideBySide
196
691
  ? Math.max(
197
692
  MASCOT_WIDTH + GUTTER_WIDTH + 24,
198
- Math.floor((innerWidth - RIGHT_COLUMN_BORDER_WIDTH) / 2),
693
+ Math.floor((innerWidth - SPLIT_DIVIDER_WIDTH) / 2),
199
694
  )
200
695
  : innerWidth;
201
696
  const rightColumnWidth = sideBySide
202
- ? Math.max(20, innerWidth - leftPanelWidth)
697
+ ? Math.max(20, innerWidth - leftPanelWidth - SPLIT_DIVIDER_WIDTH)
203
698
  : innerWidth;
204
699
  const rightInnerWidth = sideBySide
205
- ? Math.max(1, rightColumnWidth - RIGHT_COLUMN_BORDER_WIDTH)
700
+ ? Math.max(1, rightColumnWidth - 1)
206
701
  : rightColumnWidth;
207
702
  const tipsWidth = sideBySide
208
703
  ? rightInnerWidth
@@ -210,8 +705,11 @@ export function MascotDashboard({
210
705
  const copyWidth = compact
211
706
  ? available
212
707
  : sideBySide
213
- ? Math.max(18, leftPanelWidth - MASCOT_WIDTH - GUTTER_WIDTH)
708
+ ? Math.max(18, leftPanelWidth - MASCOT_WIDTH - GUTTER_WIDTH - 1)
214
709
  : innerWidth;
710
+ const wideGreetingRows = sideBySide
711
+ ? fixedDisplayRows(greeting, copyWidth, 2)
712
+ : [];
215
713
 
216
714
  if (tinyTerminal) {
217
715
  return (
@@ -221,6 +719,14 @@ export function MascotDashboard({
221
719
  Drexler™
222
720
  </Text>
223
721
  <Text color={t.primaryLight}>{greeting}</Text>
722
+ <Box marginTop={1}>
723
+ <MoodReadout
724
+ mood={mood}
725
+ progress={bootProgress}
726
+ progressColor={resolvedBarColor}
727
+ width={available}
728
+ />
729
+ </Box>
224
730
  {dealDesk ? <Box marginTop={1}>{dealDesk(Math.max(1, available))}</Box> : null}
225
731
  </Box>
226
732
  );
@@ -235,6 +741,14 @@ export function MascotDashboard({
235
741
  Drexler International™
236
742
  </Text>
237
743
  <Text color={t.primaryLight}>{greeting}</Text>
744
+ <Box marginTop={1}>
745
+ <MoodReadout
746
+ mood={mood}
747
+ progress={bootProgress}
748
+ progressColor={resolvedBarColor}
749
+ width={available}
750
+ />
751
+ </Box>
238
752
  {dealDesk ? <Box marginTop={1}>{dealDesk(Math.max(1, available))}</Box> : null}
239
753
  </Box>
240
754
  );
@@ -271,21 +785,48 @@ export function MascotDashboard({
271
785
  Drexler International™
272
786
  </Text>
273
787
  <Box height={1} />
274
- <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
+ )}
275
797
  <Box height={1} />
798
+ <MoodReadout
799
+ mood={mood}
800
+ progress={bootProgress}
801
+ progressColor={resolvedBarColor}
802
+ width={copyWidth}
803
+ />
276
804
  </Box>
277
805
  </Box>
278
806
  {sideBySide ? (
807
+ <>
808
+ <Box flexDirection="column" width={SPLIT_DIVIDER_WIDTH} flexShrink={0}>
809
+ {SPLIT_DIVIDER_ROWS.map((idx) => (
810
+ <Text key={idx} color={t.primaryDim}>
811
+ {" │ "}
812
+ </Text>
813
+ ))}
814
+ </Box>
279
815
  <Box
280
816
  flexDirection="column"
281
817
  width={rightColumnWidth}
282
- borderLeft
283
- borderColor={t.primaryDim}
284
- paddingLeft={1}
818
+ paddingRight={1}
285
819
  >
286
- <TipsPanel width={rightInnerWidth} />
287
- {dealDesk ? <Box marginTop={1}>{dealDesk(rightInnerWidth)}</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}
288
828
  </Box>
829
+ </>
289
830
  ) : (
290
831
  <Box marginTop={1} width={tipsWidth} flexDirection="column">
291
832
  <TipsPanel width={tipsWidth} />
@@ -302,7 +843,7 @@ export function MascotIntro({ greeting }: IntroProps) {
302
843
  const { exit } = useApp();
303
844
  const { stdout } = useStdout();
304
845
  const [cols, setCols] = useState(stdout?.columns ?? 80);
305
- const [frameIdx, setFrameIdx] = useState(0);
846
+ const intro = useIntroAnimation(cols, true, exit);
306
847
 
307
848
  useEffect(() => {
308
849
  if (!stdout) return;
@@ -317,56 +858,16 @@ export function MascotIntro({ greeting }: IntroProps) {
317
858
  if (key.escape || key.return || (key.ctrl && _input === "c")) exit();
318
859
  });
319
860
 
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}`;
861
+ const barColor = introPhaseColor(intro.colorPhase, t);
361
862
 
362
863
  return (
363
864
  <MascotDashboard
364
865
  greeting={greeting}
365
866
  width={cols}
366
- state={state}
367
- bar={bar}
867
+ state={intro.state}
868
+ bar={intro.bar}
368
869
  barColor={barColor}
369
- mascotStatus={mascotStatus}
870
+ mascotStatus={intro.status}
370
871
  />
371
872
  );
372
873
  }