drexler 0.2.15 → 0.2.16

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.16
4
+
5
+ - Added an interactive pet system: feed, play, work, praise, rest, vibe, name, and profile commands; persistent stats with offline decay; intern→analyst→associate→VP→MD rank ladder driven by lifetime deal accumulation; 90-second cooldowns per action with in-character rejection copy.
6
+ - Adaptive pet UI: full animated panel on wide terminals (cols ≥ 112), bordered compact panel on medium terminals (≥ 48), one-line ticker surfacing the worst stat on tiny terminals.
7
+ - Compact panel routes stats through the existing satirical level ladder (peak/good/ok/low/critical) instead of bare percentages so it matches the Deal Desk surface.
8
+ - Pet save is now atomic (temp file + rename); dead-pet command guard prevents stat mutation during the death exit timer; frame interval pauses when the pet has died.
9
+ - Hardened launch flow: validate CLI flags and config before the first-run API key prompt with reason-specific errors. Fatal handlers moved off the interactive path so Ink's signal-exit can restore the terminal cleanly.
10
+ - Markdown link parser now balances parentheses, so URLs like `https://en.wikipedia.org/wiki/Foo_(bar)` parse correctly.
11
+ - New informational commands: `/setup` prints config + API key source without leaking the key; `/update` prints upgrade instructions and refuses to run installs.
12
+ - Transcript viewport enforces a hard row budget — oversized cards clip with an explicit `... N lines truncated — PageUp scrollback to read` hint; indicators report row counts in addition to item counts; scrollback keys work while a response is streaming.
13
+ - Command palette Enter on bare argument-parent commands (`/theme`, `/model`, `/startup`, `/retry`, `/export`) now reopens the chooser instead of executing the base form; history navigation preserves the unsent draft.
14
+ - Performance: collapsed duplicate width memos, hoisted divider/carpet constants, memoized StatBar, tightened the pet panel frame loop.
15
+
3
16
  ## 0.2.14
4
17
 
5
18
  - Added a startup Mood panel with a stable boot gauge, percentage-only loading row, and rotating mood-specific posture/detail copy.
package/README.md CHANGED
@@ -91,6 +91,8 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
91
91
 
92
92
  Drexler runs as an Ink terminal UI when both stdin and stdout are TTYs. The normal launch shows one integrated startup panel with the mascot, tips, a **Mood** readout, and the **Drexler Deal Desk**. Short terminals automatically suppress oversized startup chrome so the chat stays usable.
93
93
 
94
+ After startup, the pet UI adapts to the terminal instead of disappearing. Wide terminals show the animated **Drexler Pet Desk** as a right-side panel. Medium terminals show a compact pet panel below the Deal Desk. Tiny terminals show a one-line pet ticker so pet status remains visible without crushing the chat.
95
+
94
96
  The startup panel is designed to stay stable while it boots: the mascot loading bar and Mood gauge animate without changing width, greeting copy is held in a fixed slot, and the Mood and Deal Desk boxes stay aligned when the greeting wraps. After boot, Mood resolves into a rotating Drexler-flavored posture with a short satirical subtext line.
95
97
 
96
98
  The Deal Desk is intentionally not a frontier-model telemetry panel. It shows mood-shaped corporate nonsense like boardroom status, memo count, mandate, risk, fees, and counsel posture. Values rotate by mood and session so repeated moods still feel alive.
@@ -134,6 +136,14 @@ Keyboard notes:
134
136
  | `/clear` | shred conversation history (system prompt pinned) |
135
137
  | `/exit` | meeting adjourned |
136
138
  | `/synergy` | run a rotating animated morale event |
139
+ | `/feed` | feed Drexler a deal memo |
140
+ | `/play` | corporate synergy game with Drexler |
141
+ | `/work` | Drexler grinds the deal pipeline |
142
+ | `/praise` | affirm Drexler's contributions |
143
+ | `/rest` | strategic nap |
144
+ | `/vibe` | let Drexler choose his own adventure |
145
+ | `/name [name]` | view or assign Drexler's pet name |
146
+ | `/profile` | print Drexler's personnel file |
137
147
  | `/model` | show current model, or `/model 26b` to switch |
138
148
  | `/theme` | show/switch theme; append `save` to persist, e.g. `/theme midnight save` |
139
149
  | `/startup fast\|no-intro\|normal` | persist startup behavior for future launches |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "CLI chat with Drexler, a corporate-executive AI persona built on OpenRouter Gemma 4 31B.",
5
5
  "license": "MIT",
6
6
  "author": "showOS",
package/src/index.ts CHANGED
@@ -63,6 +63,8 @@ Slash commands inside REPL:
63
63
  /rest strategic nap (restores energy)
64
64
  /praise affirm Drexler's contributions
65
65
  /vibe let Drexler choose his own adventure
66
+ /name [name] view or assign Drexler's pet name
67
+ /profile print Drexler's personnel file
66
68
  /model [id] show or switch model
67
69
  /theme [name] show or switch theme; append save to persist
68
70
  /startup [mode] persist startup mode: fast, no-intro, normal
package/src/ui/App.tsx CHANGED
@@ -28,9 +28,12 @@ import {
28
28
  } from "../pet/petState.ts";
29
29
  import { DeathScreen } from "./DeathScreen.tsx";
30
30
  import {
31
+ CompactPetPanel,
32
+ COMPACT_PET_PANEL_MIN_WIDTH,
33
+ COMPACT_PET_PANEL_ROWS,
31
34
  PetPanel,
32
- PET_PANEL_ROWS,
33
35
  PET_PANEL_WIDTH,
36
+ TINY_PET_PANEL_ROWS,
34
37
  type Environment,
35
38
  } from "./PetPanel.tsx";
36
39
  import {
@@ -90,11 +93,10 @@ import {
90
93
  import { getActiveTheme } from "./themes.ts";
91
94
 
92
95
  const TRANSCRIPT_CHROME_ROWS = 12;
93
- const PET_PANEL_MIN_MAIN_COLUMNS = 91;
96
+ const PET_PANEL_MIN_MAIN_COLUMNS = 75;
94
97
  const PET_PANEL_GAP_COLUMNS = 1;
95
98
  const PET_PANEL_MIN_COLUMNS =
96
99
  PET_PANEL_WIDTH + PET_PANEL_GAP_COLUMNS + PET_PANEL_MIN_MAIN_COLUMNS;
97
- const PET_PANEL_MIN_ROWS = TRANSCRIPT_CHROME_ROWS + PET_PANEL_ROWS;
98
100
 
99
101
  export function transcriptRowsForTerminalRows(rows: number): number {
100
102
  return Math.max(1, Math.min(24, rows - TRANSCRIPT_CHROME_ROWS));
@@ -226,14 +228,23 @@ export function App({
226
228
  const mode = useMemo(() => pickLayout(cols), [cols]);
227
229
  const chromeWidth = useMemo(() => Math.max(1, cols), [cols]);
228
230
  const isCompact = mode === "very-narrow";
231
+ const [introDone, setIntroDone] = useState(false);
229
232
  const integratedIntro =
230
- showIntroChrome && typeof greeting === "string" && rows >= 32;
231
- const showPetPanel =
232
- cols >= PET_PANEL_MIN_COLUMNS && rows >= PET_PANEL_MIN_ROWS && !integratedIntro;
233
- const petPanelReservedWidth = showPetPanel
233
+ showIntroChrome &&
234
+ typeof greeting === "string" &&
235
+ rows >= 32 &&
236
+ !introDone;
237
+ const showPetSidePanel = cols >= PET_PANEL_MIN_COLUMNS && !integratedIntro;
238
+ const showCompactPetPanel = !showPetSidePanel && !integratedIntro;
239
+ const compactPetRowBudget = showCompactPetPanel
240
+ ? cols >= COMPACT_PET_PANEL_MIN_WIDTH
241
+ ? COMPACT_PET_PANEL_ROWS
242
+ : TINY_PET_PANEL_ROWS
243
+ : 0;
244
+ const petPanelReservedWidth = showPetSidePanel
234
245
  ? PET_PANEL_WIDTH + PET_PANEL_GAP_COLUMNS
235
246
  : 0;
236
- const contentWidth = showPetPanel
247
+ const contentWidth = showPetSidePanel
237
248
  ? Math.max(1, cols - petPanelReservedWidth)
238
249
  : chromeWidth;
239
250
  const contentInputWidth = Math.max(1, contentWidth);
@@ -244,9 +255,9 @@ export function App({
244
255
  () =>
245
256
  Math.max(
246
257
  1,
247
- transcriptRowsForTerminalRows(rows) - introRowBudget,
258
+ transcriptRowsForTerminalRows(rows) - introRowBudget - compactPetRowBudget,
248
259
  ),
249
- [introRowBudget, rows],
260
+ [compactPetRowBudget, introRowBudget, rows],
250
261
  );
251
262
 
252
263
  const [items, setItems] = useState<ChatItem[]>([]);
@@ -299,7 +310,14 @@ export function App({
299
310
  const [historyIdx, setHistoryIdx] = useState<number | null>(null);
300
311
  const [paletteIdx, setPaletteIdx] = useState(0);
301
312
  const [scrollOffset, setScrollOffset] = useState(0);
302
- const intro = useIntroAnimation(chromeWidth, integratedIntro);
313
+ const handleIntroComplete = useCallback(() => {
314
+ setIntroDone(true);
315
+ }, []);
316
+ const intro = useIntroAnimation(
317
+ chromeWidth,
318
+ integratedIntro,
319
+ handleIntroComplete,
320
+ );
303
321
 
304
322
  const [petStats, setPetStats] = useState<PetStats>(() => loadPetState());
305
323
  const [petActivity, setPetActivity] = useState<PetActivity>("idle");
@@ -405,11 +423,6 @@ export function App({
405
423
 
406
424
  // Real-time stat decay matches the offline per-hour decay rate.
407
425
  useEffect(() => {
408
- if (!showPetPanel) {
409
- return () => {
410
- savePetState(petStatsRef.current);
411
- };
412
- }
413
426
  petDecayTimerRef.current = setInterval(() => {
414
427
  updatePetStats(applyMinuteDecay);
415
428
  }, 60_000);
@@ -424,11 +437,11 @@ export function App({
424
437
  }
425
438
  savePetState(petStatsRef.current);
426
439
  };
427
- }, [showPetPanel, updatePetStats]);
440
+ }, [updatePetStats]);
428
441
 
429
442
  // Death detection
430
443
  useEffect(() => {
431
- if (!showPetPanel || isDead || !isPetDead(petStats)) return;
444
+ if (isDead || !isPetDead(petStats)) return;
432
445
  const reason =
433
446
  petStats.hunger <= 0 ? "hunger" :
434
447
  petStats.happiness <= 0 ? "happiness" : "energy";
@@ -439,7 +452,7 @@ export function App({
439
452
  petStatsRef.current = deadStats;
440
453
  savePetState(deadStats);
441
454
  exitTimerRef.current = setTimeout(() => exit(), 5000);
442
- }, [petStats, showPetPanel, isDead, exit]);
455
+ }, [petStats, isDead, exit]);
443
456
 
444
457
  const paletteItems = useMemo(() => filterPaletteByPrefix(input), [input]);
445
458
  const paletteOpen = paletteItems.length > 0;
@@ -1152,8 +1165,19 @@ export function App({
1152
1165
  ) : (
1153
1166
  dealDeskHeader
1154
1167
  )}
1168
+ {showCompactPetPanel && (
1169
+ <Box marginBottom={1}>
1170
+ <CompactPetPanel
1171
+ stats={petStats}
1172
+ activity={petActivity}
1173
+ env={petEnv}
1174
+ isPaused={isBusy}
1175
+ width={chromeWidth}
1176
+ />
1177
+ </Box>
1178
+ )}
1155
1179
  <Box flexDirection="row" alignItems="flex-start">
1156
- {showPetPanel && (
1180
+ {showPetSidePanel && (
1157
1181
  <Box marginRight={PET_PANEL_GAP_COLUMNS}>
1158
1182
  <PetPanel
1159
1183
  stats={petStats}
@@ -7,12 +7,16 @@ import { type Theme } from "./themes.ts";
7
7
 
8
8
  export const PET_PANEL_WIDTH = 36;
9
9
  export const PET_PANEL_ROWS = 22;
10
+ export const COMPACT_PET_PANEL_ROWS = 5;
11
+ export const TINY_PET_PANEL_ROWS = 1;
12
+ export const COMPACT_PET_PANEL_MIN_WIDTH = 48;
10
13
  export type Environment = "office" | "home" | "outdoors";
11
14
 
12
15
  const PANEL_BORDER_COLUMNS = 2;
13
16
  const PANEL_PADDING_COLUMNS = 2;
14
17
  const CONTENT = PET_PANEL_WIDTH - PANEL_BORDER_COLUMNS - PANEL_PADDING_COLUMNS;
15
18
  const SPRITE_W = 8;
19
+ const DIVIDER_LINE = "─".repeat(CONTENT);
16
20
 
17
21
  const R_SKY = 0;
18
22
  const R_BGA = 1;
@@ -454,6 +458,10 @@ interface PetPanelProps {
454
458
  isPaused?: boolean;
455
459
  }
456
460
 
461
+ interface CompactPetPanelProps extends PetPanelProps {
462
+ width: number;
463
+ }
464
+
457
465
  function PetPanelView({ stats, activity, env = "office", isPaused = false }: PetPanelProps) {
458
466
  const t = useTheme();
459
467
  const [frame, setFrame] = useState(0);
@@ -463,12 +471,15 @@ function PetPanelView({ stats, activity, env = "office", isPaused = false }: Pet
463
471
  }, [activity, env]);
464
472
 
465
473
  useEffect(() => {
466
- if (isPaused) return;
474
+ // Skip frame ticks when paused or when the pet has died — DeathScreen
475
+ // takes over the UI, no point burning a setInterval that mutates state
476
+ // nothing will read.
477
+ if (isPaused || stats.dead === true) return;
467
478
  const id = setInterval(() => {
468
479
  setFrame((f) => f + 1);
469
480
  }, 800);
470
481
  return () => clearInterval(id);
471
- }, [isPaused]);
482
+ }, [isPaused, stats.dead]);
472
483
 
473
484
  const scene = useMemo(
474
485
  () => buildScene(activity, frame, stats, env),
@@ -479,7 +490,7 @@ function PetPanelView({ stats, activity, env = "office", isPaused = false }: Pet
479
490
  () => pad(`memo ${getStatusMsg(stats, frame)}`, CONTENT),
480
491
  [stats, frame],
481
492
  );
482
- const title = fitDisplayText(`DREXLER DEAL DESK [${env}]`, CONTENT);
493
+ const title = fitDisplayText(`DREXLER PET DESK [${env}]`, CONTENT);
483
494
  const activityLabel = activity !== "idle" ? ` / ${activity}` : "";
484
495
  const moodLabel = `mood ${mood}`;
485
496
  const fittedMood = activityLabel
@@ -508,7 +519,7 @@ function PetPanelView({ stats, activity, env = "office", isPaused = false }: Pet
508
519
  </Box>
509
520
 
510
521
  <Box paddingX={1}>
511
- <Text color={t.primaryDim}>{"─".repeat(CONTENT)}</Text>
522
+ <Text color={t.primaryDim}>{DIVIDER_LINE}</Text>
512
523
  </Box>
513
524
 
514
525
  <Box flexDirection="column" paddingX={1}>
@@ -519,7 +530,7 @@ function PetPanelView({ stats, activity, env = "office", isPaused = false }: Pet
519
530
  </Box>
520
531
 
521
532
  <Box paddingX={1}>
522
- <Text color={t.primaryDim}>{"─".repeat(CONTENT)}</Text>
533
+ <Text color={t.primaryDim}>{DIVIDER_LINE}</Text>
523
534
  </Box>
524
535
 
525
536
  <Box paddingX={1}>
@@ -535,3 +546,128 @@ function PetPanelView({ stats, activity, env = "office", isPaused = false }: Pet
535
546
  }
536
547
 
537
548
  export const PetPanel = memo(PetPanelView);
549
+
550
+ function pct(value: number): string {
551
+ return `${Math.round(value)}%`;
552
+ }
553
+
554
+ const STAT_LEVEL_LABEL: Record<MsgLevel, string> = {
555
+ critical: "critical",
556
+ low: "low",
557
+ ok: "ok",
558
+ good: "good",
559
+ great: "peak",
560
+ };
561
+
562
+ interface CompactStatProfile {
563
+ hunger: MsgLevel;
564
+ happiness: MsgLevel;
565
+ energy: MsgLevel;
566
+ deals: MsgLevel;
567
+ }
568
+
569
+ function compactStatProfile(stats: PetStats): CompactStatProfile {
570
+ return {
571
+ hunger: statLevel(stats.hunger),
572
+ happiness: statLevel(stats.happiness),
573
+ energy: statLevel(stats.energy),
574
+ deals: statLevel(stats.deals),
575
+ };
576
+ }
577
+
578
+ interface WorstStat {
579
+ key: "hunger" | "happiness" | "energy" | "deals";
580
+ value: number;
581
+ }
582
+
583
+ export function pickWorstStat(stats: PetStats): WorstStat {
584
+ const entries: WorstStat[] = [
585
+ { key: "hunger", value: stats.hunger },
586
+ { key: "happiness", value: stats.happiness },
587
+ { key: "energy", value: stats.energy },
588
+ { key: "deals", value: stats.deals },
589
+ ];
590
+ return entries.reduce((best, cur) =>
591
+ cur.value < best.value ? cur : best,
592
+ );
593
+ }
594
+
595
+ function CompactPetPanelView({
596
+ stats,
597
+ activity,
598
+ env = "office",
599
+ isPaused = false,
600
+ width,
601
+ }: CompactPetPanelProps) {
602
+ const t = useTheme();
603
+ const safeWidth = Math.max(1, width);
604
+
605
+ // Rotate the memo every 10s so the compact panel doesn't feel static.
606
+ // Paused panels lock to a single message (no decay-tick to refresh anyway).
607
+ const [tick, setTick] = useState(() => Math.floor(Date.now() / 10_000));
608
+ useEffect(() => {
609
+ if (isPaused) return;
610
+ const id = setInterval(() => {
611
+ setTick(Math.floor(Date.now() / 10_000));
612
+ }, 10_000);
613
+ return () => clearInterval(id);
614
+ }, [isPaused]);
615
+
616
+ const mood = getPetMood(stats);
617
+ const profile = compactStatProfile(stats);
618
+ const activityCopy = activity === "idle" ? env : `${env} / ${activity}`;
619
+ const title = "Drexler Pet Desk";
620
+ const statLine = [
621
+ `happy ${STAT_LEVEL_LABEL[profile.happiness]}`,
622
+ `hungr ${STAT_LEVEL_LABEL[profile.hunger]}`,
623
+ `enrgy ${STAT_LEVEL_LABEL[profile.energy]}`,
624
+ `deals ${STAT_LEVEL_LABEL[profile.deals]}`,
625
+ ].join(" · ");
626
+ const statusLine = `memo ${getStatusMsg(stats, tick)}`;
627
+
628
+ if (safeWidth < COMPACT_PET_PANEL_MIN_WIDTH) {
629
+ // Worst stat drives the ticker so an idle eye still catches a failing
630
+ // metric instead of a fixed happy/energy readout.
631
+ const worst = pickWorstStat(stats);
632
+ const worstLevel = statLevel(worst.value);
633
+ const accent = worstLevel === "critical" || worstLevel === "low"
634
+ ? t.warning
635
+ : t.primary;
636
+ return (
637
+ <Box width={safeWidth} flexShrink={1}>
638
+ <Text color={accent}>
639
+ {fitDisplayText(
640
+ `pet ${mood} · ${worst.key} ${pct(worst.value)} (${worstLevel})`,
641
+ safeWidth,
642
+ )}
643
+ </Text>
644
+ </Box>
645
+ );
646
+ }
647
+
648
+ const innerWidth = Math.max(1, safeWidth - PANEL_BORDER_COLUMNS - PANEL_PADDING_COLUMNS);
649
+ const header = `${title} [${activityCopy}]`;
650
+
651
+ return (
652
+ <Box
653
+ flexDirection="column"
654
+ width={safeWidth}
655
+ flexShrink={1}
656
+ borderStyle="round"
657
+ borderColor={t.primaryDim}
658
+ paddingX={1}
659
+ >
660
+ <Box>
661
+ <Text color={t.primary} bold>{fitDisplayText(header, innerWidth)}</Text>
662
+ </Box>
663
+ <Box>
664
+ <Text color={t.text}>{fitDisplayText(statLine, innerWidth)}</Text>
665
+ </Box>
666
+ <Box>
667
+ <Text color={t.dim}>{fitDisplayText(`${mood} · ${statusLine}`, innerWidth)}</Text>
668
+ </Box>
669
+ </Box>
670
+ );
671
+ }
672
+
673
+ export const CompactPetPanel = memo(CompactPetPanelView);