drexler 0.2.15 → 0.2.17

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
@@ -89,7 +89,9 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
89
89
 
90
90
  ### Interactive UI
91
91
 
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.
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**. After startup, that dashboard remains the primary top chrome. Short terminals automatically suppress oversized startup chrome so the chat stays usable.
93
+
94
+ Type `/pet` to toggle pet mode for the current session. Pet mode keeps the top dashboard frame and swaps the normal mascot/tips/deal desk layout for a Drexler scene plus pet stats. `/pet on` and `/pet off` set it explicitly. Pet stats still decay and pet commands still work when the pet dashboard is hidden.
93
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
 
@@ -134,6 +136,15 @@ 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
+ | `/pet [on\|off]` | toggle pet dashboard mode for this session |
140
+ | `/feed` | feed Drexler a deal memo |
141
+ | `/play` | corporate synergy game with Drexler |
142
+ | `/work` | Drexler grinds the deal pipeline |
143
+ | `/praise` | affirm Drexler's contributions |
144
+ | `/rest` | strategic nap |
145
+ | `/vibe` | let Drexler choose his own adventure |
146
+ | `/name [name]` | view or assign Drexler's pet name |
147
+ | `/profile` | print Drexler's personnel file |
137
148
  | `/model` | show current model, or `/model 26b` to switch |
138
149
  | `/theme` | show/switch theme; append `save` to persist, e.g. `/theme midnight save` |
139
150
  | `/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.17",
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/commands.ts CHANGED
@@ -47,6 +47,7 @@ const HELP_TEXT = `New memo to staff! Drexler permit following directives:
47
47
  /clear - shred all documents (reset history)
48
48
  /exit - meeting adjourned
49
49
  /synergy - SYNERGY!
50
+ /pet [on|off] - toggle pet dashboard mode
50
51
  /feed - feed Drexler a deal memo
51
52
  /play - corporate synergy game with Drexler
52
53
  /work - Drexler grinds the deal pipeline
@@ -86,6 +87,7 @@ export const COMMAND_PALETTE: ReadonlyArray<SlashCommand> = [
86
87
  { name: "/clear", description: "Reset conversation", group: "directives" },
87
88
  { name: "/exit", description: "Adjourn meeting", group: "directives" },
88
89
  { name: "/synergy", description: "SYNERGY!", group: "directives" },
90
+ { name: "/pet", description: "Toggle pet dashboard mode", group: "directives" },
89
91
  { name: "/feed", description: "Feed Drexler a deal memo", group: "directives" },
90
92
  { name: "/play", description: "Play with Drexler", group: "directives" },
91
93
  { name: "/work", description: "Drexler grinds deals", group: "directives" },
@@ -369,6 +371,7 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
369
371
  );
370
372
  return { type: "continue" };
371
373
 
374
+ case "pet":
372
375
  case "feed":
373
376
  case "play":
374
377
  case "work":
package/src/index.ts CHANGED
@@ -57,12 +57,15 @@ Slash commands inside REPL:
57
57
  /clear reset conversation
58
58
  /exit exit
59
59
  /synergy SYNERGY!
60
+ /pet [on|off] toggle pet dashboard mode
60
61
  /feed feed Drexler a deal memo
61
62
  /play corporate synergy game (flexing included)
62
63
  /work Drexler grinds the pipeline
63
64
  /rest strategic nap (restores energy)
64
65
  /praise affirm Drexler's contributions
65
66
  /vibe let Drexler choose his own adventure
67
+ /name [name] view or assign Drexler's pet name
68
+ /profile print Drexler's personnel file
66
69
  /model [id] show or switch model
67
70
  /theme [name] show or switch theme; append save to persist
68
71
  /startup [mode] persist startup mode: fast, no-intro, normal
package/src/ui/App.tsx CHANGED
@@ -28,9 +28,10 @@ import {
28
28
  } from "../pet/petState.ts";
29
29
  import { DeathScreen } from "./DeathScreen.tsx";
30
30
  import {
31
- PetPanel,
32
- PET_PANEL_ROWS,
33
- PET_PANEL_WIDTH,
31
+ CompactPetPanel,
32
+ COMPACT_PET_PANEL_MIN_WIDTH,
33
+ COMPACT_PET_PANEL_ROWS,
34
+ TINY_PET_PANEL_ROWS,
34
35
  type Environment,
35
36
  } from "./PetPanel.tsx";
36
37
  import {
@@ -90,11 +91,6 @@ import {
90
91
  import { getActiveTheme } from "./themes.ts";
91
92
 
92
93
  const TRANSCRIPT_CHROME_ROWS = 12;
93
- const PET_PANEL_MIN_MAIN_COLUMNS = 91;
94
- const PET_PANEL_GAP_COLUMNS = 1;
95
- const PET_PANEL_MIN_COLUMNS =
96
- 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
94
 
99
95
  export function transcriptRowsForTerminalRows(rows: number): number {
100
96
  return Math.max(1, Math.min(24, rows - TRANSCRIPT_CHROME_ROWS));
@@ -196,6 +192,7 @@ interface AppProps {
196
192
  fetchFn?: FetchFn;
197
193
  greeting?: string;
198
194
  showIntroChrome?: boolean;
195
+ introInitiallyDone?: boolean;
199
196
  }
200
197
 
201
198
  export function App({
@@ -205,6 +202,7 @@ export function App({
205
202
  fetchFn,
206
203
  greeting,
207
204
  showIntroChrome = false,
205
+ introInitiallyDone = false,
208
206
  }: AppProps) {
209
207
  const { exit } = useApp();
210
208
  const { stdout } = useStdout();
@@ -226,27 +224,38 @@ export function App({
226
224
  const mode = useMemo(() => pickLayout(cols), [cols]);
227
225
  const chromeWidth = useMemo(() => Math.max(1, cols), [cols]);
228
226
  const isCompact = mode === "very-narrow";
229
- 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
234
- ? PET_PANEL_WIDTH + PET_PANEL_GAP_COLUMNS
227
+ const [introDone, setIntroDone] = useState(introInitiallyDone);
228
+ const dashboardAllowed = showIntroChrome && typeof greeting === "string";
229
+ const showFullDashboard = dashboardAllowed && rows >= 32;
230
+ const introActive = showFullDashboard && !introDone;
231
+ const [petMode, setPetMode] = useState(false);
232
+ const petModeRef = useRef(false);
233
+ const showFallbackPetPanel = petMode && !showFullDashboard;
234
+ const fallbackPetRowBudget = showFallbackPetPanel
235
+ ? cols >= COMPACT_PET_PANEL_MIN_WIDTH
236
+ ? COMPACT_PET_PANEL_ROWS
237
+ : TINY_PET_PANEL_ROWS
235
238
  : 0;
236
- const contentWidth = showPetPanel
237
- ? Math.max(1, cols - petPanelReservedWidth)
238
- : chromeWidth;
239
+ const contentWidth = chromeWidth;
239
240
  const contentInputWidth = Math.max(1, contentWidth);
240
241
  const contentStatusWidth = Math.max(1, contentInputWidth - 2);
241
- const introRowBudget =
242
- integratedIntro ? (chromeWidth >= 112 ? 14 : chromeWidth >= 72 ? 26 : 6) : 0;
242
+ const dashboardRowBudget =
243
+ showFullDashboard
244
+ ? chromeWidth >= 112
245
+ ? 14
246
+ : chromeWidth >= 72
247
+ ? 26
248
+ : 6
249
+ : 0;
243
250
  const maxTranscriptRows = useMemo(
244
251
  () =>
245
252
  Math.max(
246
253
  1,
247
- transcriptRowsForTerminalRows(rows) - introRowBudget,
254
+ transcriptRowsForTerminalRows(rows) -
255
+ dashboardRowBudget -
256
+ fallbackPetRowBudget,
248
257
  ),
249
- [introRowBudget, rows],
258
+ [dashboardRowBudget, fallbackPetRowBudget, rows],
250
259
  );
251
260
 
252
261
  const [items, setItems] = useState<ChatItem[]>([]);
@@ -299,7 +308,14 @@ export function App({
299
308
  const [historyIdx, setHistoryIdx] = useState<number | null>(null);
300
309
  const [paletteIdx, setPaletteIdx] = useState(0);
301
310
  const [scrollOffset, setScrollOffset] = useState(0);
302
- const intro = useIntroAnimation(chromeWidth, integratedIntro);
311
+ const handleIntroComplete = useCallback(() => {
312
+ setIntroDone(true);
313
+ }, []);
314
+ const intro = useIntroAnimation(
315
+ chromeWidth,
316
+ introActive,
317
+ handleIntroComplete,
318
+ );
303
319
 
304
320
  const [petStats, setPetStats] = useState<PetStats>(() => loadPetState());
305
321
  const [petActivity, setPetActivity] = useState<PetActivity>("idle");
@@ -373,6 +389,10 @@ export function App({
373
389
  },
374
390
  [],
375
391
  );
392
+ const setDashboardPetMode = useCallback((next: boolean) => {
393
+ petModeRef.current = next;
394
+ setPetMode(next);
395
+ }, []);
376
396
  const updatePetStats = useCallback((updater: (stats: PetStats) => PetStats) => {
377
397
  setPetStats((stats) => {
378
398
  const next = updater(stats);
@@ -405,11 +425,6 @@ export function App({
405
425
 
406
426
  // Real-time stat decay matches the offline per-hour decay rate.
407
427
  useEffect(() => {
408
- if (!showPetPanel) {
409
- return () => {
410
- savePetState(petStatsRef.current);
411
- };
412
- }
413
428
  petDecayTimerRef.current = setInterval(() => {
414
429
  updatePetStats(applyMinuteDecay);
415
430
  }, 60_000);
@@ -424,11 +439,11 @@ export function App({
424
439
  }
425
440
  savePetState(petStatsRef.current);
426
441
  };
427
- }, [showPetPanel, updatePetStats]);
442
+ }, [updatePetStats]);
428
443
 
429
444
  // Death detection
430
445
  useEffect(() => {
431
- if (!showPetPanel || isDead || !isPetDead(petStats)) return;
446
+ if (isDead || !isPetDead(petStats)) return;
432
447
  const reason =
433
448
  petStats.hunger <= 0 ? "hunger" :
434
449
  petStats.happiness <= 0 ? "happiness" : "energy";
@@ -439,7 +454,7 @@ export function App({
439
454
  petStatsRef.current = deadStats;
440
455
  savePetState(deadStats);
441
456
  exitTimerRef.current = setTimeout(() => exit(), 5000);
442
- }, [petStats, showPetPanel, isDead, exit]);
457
+ }, [petStats, isDead, exit]);
443
458
 
444
459
  const paletteItems = useMemo(() => filterPaletteByPrefix(input), [input]);
445
460
  const paletteOpen = paletteItems.length > 0;
@@ -674,6 +689,38 @@ export function App({
674
689
  runSynergyEvent();
675
690
  return;
676
691
  }
692
+ if (slashCommand === "/pet") {
693
+ const arg = lower.slice("/pet".length).trim();
694
+ if (arg === "") {
695
+ const next = !petModeRef.current;
696
+ setDashboardPetMode(next);
697
+ addItem(
698
+ "system",
699
+ next
700
+ ? "Pet dashboard enabled. Deal desk converted to habitat."
701
+ : "Pet dashboard disabled. Deal desk restored.",
702
+ );
703
+ return;
704
+ }
705
+ if (arg === "on" || arg === "off") {
706
+ const next = arg === "on";
707
+ const changed = petModeRef.current !== next;
708
+ setDashboardPetMode(next);
709
+ addItem(
710
+ "system",
711
+ changed
712
+ ? next
713
+ ? "Pet dashboard enabled. Deal desk converted to habitat."
714
+ : "Pet dashboard disabled. Deal desk restored."
715
+ : next
716
+ ? "Pet dashboard already enabled."
717
+ : "Pet dashboard already disabled.",
718
+ );
719
+ return;
720
+ }
721
+ addItem("system", "Usage: /pet, /pet on, or /pet off.");
722
+ return;
723
+ }
677
724
  const isPetCommand =
678
725
  slashCommand === "/feed" ||
679
726
  slashCommand === "/play" ||
@@ -848,11 +895,11 @@ export function App({
848
895
  config,
849
896
  activeTheme,
850
897
  model,
851
- petStats,
852
898
  PET_MESSAGES,
853
899
  removeLastAssistantItem,
854
900
  runLLM,
855
901
  runSynergyEvent,
902
+ setDashboardPetMode,
856
903
  triggerExit,
857
904
  triggerPetActivity,
858
905
  updatePetStats,
@@ -1116,9 +1163,9 @@ export function App({
1116
1163
  messageCount={msgCount}
1117
1164
  status={headerStatus}
1118
1165
  compact={isCompact}
1119
- notice={!integratedIntro ? deskNotice ?? undefined : undefined}
1166
+ notice={!introActive ? deskNotice ?? undefined : undefined}
1120
1167
  maxWidth={Math.max(1, width)}
1121
- marginBottom={integratedIntro ? 0 : 1}
1168
+ marginBottom={introActive ? 0 : 1}
1122
1169
  />
1123
1170
  );
1124
1171
  const dealDeskHeader = renderDealDeskHeader(chromeWidth);
@@ -1135,34 +1182,39 @@ export function App({
1135
1182
  return (
1136
1183
  <ThemeProvider value={activeTheme}>
1137
1184
  <Box flexDirection="column">
1138
- {integratedIntro ? (
1185
+ {showFullDashboard && typeof greeting === "string" ? (
1139
1186
  <Box marginBottom={1}>
1140
1187
  <MascotDashboard
1141
1188
  greeting={greeting}
1142
1189
  width={chromeWidth}
1143
1190
  mood={mood}
1144
- bootProgress={intro.progress}
1145
- state={intro.state}
1146
- bar={intro.bar}
1147
- barColor={introBarColor}
1148
- mascotStatus={intro.status}
1191
+ mode={petMode && !introActive ? "pet" : "normal"}
1192
+ petStats={petStats}
1193
+ petActivity={petActivity}
1194
+ petEnv={petEnv}
1195
+ petPaused={isBusy}
1196
+ bootProgress={introActive ? intro.progress : 1}
1197
+ state={introActive ? intro.state : undefined}
1198
+ bar={introActive ? intro.bar : undefined}
1199
+ barColor={introActive ? introBarColor : undefined}
1200
+ mascotStatus={introActive ? intro.status : undefined}
1149
1201
  dealDesk={renderDealDeskHeader}
1150
1202
  />
1151
1203
  </Box>
1204
+ ) : showFallbackPetPanel ? (
1205
+ <Box marginBottom={1}>
1206
+ <CompactPetPanel
1207
+ stats={petStats}
1208
+ activity={petActivity}
1209
+ env={petEnv}
1210
+ isPaused={isBusy}
1211
+ width={chromeWidth}
1212
+ />
1213
+ </Box>
1152
1214
  ) : (
1153
1215
  dealDeskHeader
1154
1216
  )}
1155
1217
  <Box flexDirection="row" alignItems="flex-start">
1156
- {showPetPanel && (
1157
- <Box marginRight={PET_PANEL_GAP_COLUMNS}>
1158
- <PetPanel
1159
- stats={petStats}
1160
- activity={petActivity}
1161
- env={petEnv}
1162
- isPaused={isBusy}
1163
- />
1164
- </Box>
1165
- )}
1166
1218
  <Box flexDirection="column" flexGrow={1}>
1167
1219
  <TranscriptViewport
1168
1220
  items={items}
@@ -1,5 +1,14 @@
1
1
  import { Box, Text, useApp, useInput, useStdout } from "ink";
2
2
  import { useEffect, useMemo, useState, type ReactNode } from "react";
3
+ import {
4
+ formatTenure,
5
+ getPetMood,
6
+ getPetRank,
7
+ petTenureMs,
8
+ rankLabel,
9
+ type PetActivity,
10
+ type PetStats,
11
+ } from "../pet/petState.ts";
3
12
  import { STARTUP_TIPS } from "../startupTips.ts";
4
13
  import {
5
14
  MascotFrame,
@@ -7,6 +16,14 @@ import {
7
16
  type MascotState,
8
17
  } from "./MascotFrame.tsx";
9
18
  import { displayWidth, fitDisplayText } from "./graphemes.ts";
19
+ import {
20
+ COMPACT_PET_PANEL_MIN_WIDTH,
21
+ CompactPetPanel,
22
+ getPetStatusMessage,
23
+ PetScene,
24
+ PET_SCENE_WIDTH,
25
+ type Environment,
26
+ } from "./PetPanel.tsx";
10
27
  import { useTheme } from "./ThemeContext.tsx";
11
28
 
12
29
  interface IntroFrame extends MascotState {
@@ -152,6 +169,13 @@ const MAX_MOOD_PANEL_WIDTH = 44;
152
169
  const RIGHT_COLUMN_INSET = 1;
153
170
  const RIGHT_COLUMN_PAD_RIGHT = 1;
154
171
  const LEFT_PANEL_MIN_COPY = 24;
172
+ const PET_STATS_MIN_WIDTH = 24;
173
+ const PET_STATS_MAX_WIDTH = 58;
174
+ const PET_SPLIT_DIVIDER_HEIGHT = 12;
175
+ const PET_SPLIT_DIVIDER_ROWS: number[] = Array.from(
176
+ { length: PET_SPLIT_DIVIDER_HEIGHT },
177
+ (_, i) => i,
178
+ );
155
179
 
156
180
  export type MascotLayoutMode = "tiny" | "compact" | "stacked" | "split";
157
181
 
@@ -257,6 +281,11 @@ interface MascotDashboardProps {
257
281
  greeting: string;
258
282
  width: number;
259
283
  mood?: string;
284
+ mode?: "normal" | "pet";
285
+ petStats?: PetStats;
286
+ petActivity?: PetActivity;
287
+ petEnv?: Environment;
288
+ petPaused?: boolean;
260
289
  bootProgress?: number;
261
290
  state?: MascotState;
262
291
  bar?: string;
@@ -780,10 +809,357 @@ function MoodReadout({
780
809
  );
781
810
  }
782
811
 
812
+ function padDisplayText(input: string, width: number): string {
813
+ const safeWidth = Math.max(1, width);
814
+ const fitted = fitDisplayText(input, safeWidth);
815
+ return `${fitted}${" ".repeat(Math.max(0, safeWidth - displayWidth(fitted)))}`;
816
+ }
817
+
818
+ function PetSceneReadout({
819
+ stats,
820
+ activity,
821
+ env,
822
+ isPaused,
823
+ width,
824
+ }: {
825
+ stats: PetStats;
826
+ activity: PetActivity;
827
+ env: Environment;
828
+ isPaused: boolean;
829
+ width: number;
830
+ }) {
831
+ const t = useTheme();
832
+ const safeWidth = Math.max(1, Math.floor(width));
833
+
834
+ if (safeWidth < PET_SCENE_WIDTH) {
835
+ return (
836
+ <CompactPetPanel
837
+ stats={stats}
838
+ activity={activity}
839
+ env={env}
840
+ isPaused={isPaused}
841
+ width={safeWidth}
842
+ />
843
+ );
844
+ }
845
+
846
+ return (
847
+ <Box flexDirection="column" width={safeWidth} alignItems="center">
848
+ <Text bold color={t.primaryLight}>
849
+ {fitDisplayText(`Drexler Pet Desk [${env}]`, safeWidth)}
850
+ </Text>
851
+ <PetScene
852
+ stats={stats}
853
+ activity={activity}
854
+ env={env}
855
+ isPaused={isPaused}
856
+ />
857
+ </Box>
858
+ );
859
+ }
860
+
861
+ function PetStatsBodyLine({
862
+ text,
863
+ width,
864
+ color,
865
+ }: {
866
+ text: string;
867
+ width: number;
868
+ color: string;
869
+ }) {
870
+ const t = useTheme();
871
+ const innerWidth = Math.max(1, width - 4);
872
+ const content = padDisplayText(text, innerWidth);
873
+ return (
874
+ <Text>
875
+ <Text color={t.primary}>│ </Text>
876
+ <Text color={color}>{content}</Text>
877
+ <Text color={t.primary}> │</Text>
878
+ </Text>
879
+ );
880
+ }
881
+
882
+ function PetDashboardStatBar({
883
+ label,
884
+ value,
885
+ width,
886
+ }: {
887
+ label: string;
888
+ value: number;
889
+ width: number;
890
+ }) {
891
+ const t = useTheme();
892
+ const innerWidth = Math.max(1, width - 4);
893
+ const pct = `${Math.round(value).toString().padStart(3)}%`;
894
+ const labelText = padDisplayText(label, Math.min(7, innerWidth));
895
+ const prefixWidth = displayWidth(labelText);
896
+ const barWidth = Math.max(
897
+ 1,
898
+ innerWidth - prefixWidth - displayWidth(pct) - 2,
899
+ );
900
+ const bounded = Math.max(0, Math.min(100, value));
901
+ const filled = Math.max(
902
+ 0,
903
+ Math.min(barWidth, Math.round((bounded / 100) * barWidth)),
904
+ );
905
+ const empty = Math.max(0, barWidth - filled);
906
+ const bar = `${"█".repeat(filled)}${"░".repeat(empty)}`;
907
+ const used = prefixWidth + 1 + displayWidth(bar) + 1 + displayWidth(pct);
908
+ const isLow = value < 25;
909
+ const barColor = isLow
910
+ ? t.warning
911
+ : label === "deals"
912
+ ? t.primaryDim
913
+ : t.primaryLight;
914
+
915
+ if (innerWidth < 14) {
916
+ return (
917
+ <PetStatsBodyLine
918
+ text={`${label} ${pct}`}
919
+ width={width}
920
+ color={isLow ? t.warning : t.text}
921
+ />
922
+ );
923
+ }
924
+
925
+ return (
926
+ <Text>
927
+ <Text color={t.primary}>│ </Text>
928
+ <Text color={t.dim}>{labelText} </Text>
929
+ <Text color={barColor}>{bar}</Text>
930
+ <Text color={isLow ? t.warning : t.dim}> {pct}</Text>
931
+ <Text color={t.primary}>
932
+ {" ".repeat(Math.max(0, innerWidth - used))} │
933
+ </Text>
934
+ </Text>
935
+ );
936
+ }
937
+
938
+ function PetStatsReadout({
939
+ stats,
940
+ activity,
941
+ env,
942
+ width,
943
+ }: {
944
+ stats: PetStats;
945
+ activity: PetActivity;
946
+ env: Environment;
947
+ width: number;
948
+ }) {
949
+ const t = useTheme();
950
+ const panelWidth = Math.max(
951
+ 1,
952
+ Math.min(PET_STATS_MAX_WIDTH, Math.floor(width)),
953
+ );
954
+ const innerWidth = Math.max(1, panelWidth - 4);
955
+ const mood = getPetMood(stats);
956
+ const rank = rankLabel(getPetRank(stats));
957
+ const name = stats.name ?? "Drexler";
958
+ const activityLabel = activity === "idle" ? "idle" : activity;
959
+ const title = "Pet Stats";
960
+ const topPrefix = "╭─ ";
961
+ const topSuffix = " ";
962
+ const topRule = "─".repeat(
963
+ Math.max(
964
+ 0,
965
+ panelWidth -
966
+ displayWidth(topPrefix) -
967
+ displayWidth(title) -
968
+ displayWidth(topSuffix) -
969
+ displayWidth("╮"),
970
+ ),
971
+ );
972
+ const memo = `memo ${getPetStatusMessage(stats, 0)}`;
973
+
974
+ if (panelWidth < PET_STATS_MIN_WIDTH) {
975
+ return (
976
+ <Box flexDirection="column" width={panelWidth}>
977
+ <Text bold color={t.primaryLight}>
978
+ {fitDisplayText("Pet Stats", panelWidth)}
979
+ </Text>
980
+ <Text color={t.text}>
981
+ {fitDisplayText(`${name} / ${rank}`, panelWidth)}
982
+ </Text>
983
+ <Text color={t.dim}>
984
+ {fitDisplayText(`${mood} / ${activityLabel}`, panelWidth)}
985
+ </Text>
986
+ </Box>
987
+ );
988
+ }
989
+
990
+ return (
991
+ <Box flexDirection="column" width={panelWidth}>
992
+ <Text color={t.primary}>
993
+ {topPrefix}
994
+ <Text bold color={t.primaryLight}>{title}</Text>
995
+ {topSuffix}
996
+ {topRule}
997
+
998
+ </Text>
999
+ <PetStatsBodyLine
1000
+ text={`name ${name}`}
1001
+ width={panelWidth}
1002
+ color={t.text}
1003
+ />
1004
+ <PetStatsBodyLine
1005
+ text={`rank ${rank} · mood ${mood}`}
1006
+ width={panelWidth}
1007
+ color={t.primaryLight}
1008
+ />
1009
+ <PetStatsBodyLine
1010
+ text={`activity ${activityLabel} · env ${env}`}
1011
+ width={panelWidth}
1012
+ color={t.dim}
1013
+ />
1014
+ <PetStatsBodyLine
1015
+ text={`tenure ${formatTenure(petTenureMs(stats))}`}
1016
+ width={panelWidth}
1017
+ color={t.dim}
1018
+ />
1019
+ <PetStatsBodyLine
1020
+ text={"─".repeat(innerWidth)}
1021
+ width={panelWidth}
1022
+ color={t.primaryDim}
1023
+ />
1024
+ <PetDashboardStatBar
1025
+ label="happy"
1026
+ value={stats.happiness}
1027
+ width={panelWidth}
1028
+ />
1029
+ <PetDashboardStatBar
1030
+ label="hunger"
1031
+ value={stats.hunger}
1032
+ width={panelWidth}
1033
+ />
1034
+ <PetDashboardStatBar
1035
+ label="energy"
1036
+ value={stats.energy}
1037
+ width={panelWidth}
1038
+ />
1039
+ <PetDashboardStatBar
1040
+ label="deals"
1041
+ value={stats.deals}
1042
+ width={panelWidth}
1043
+ />
1044
+ <PetStatsBodyLine
1045
+ text={memo}
1046
+ width={panelWidth}
1047
+ color={t.dim}
1048
+ />
1049
+ <Text color={t.primary}>{titledPanelBottom(panelWidth)}</Text>
1050
+ </Box>
1051
+ );
1052
+ }
1053
+
1054
+ function PetDashboard({
1055
+ layout,
1056
+ stats,
1057
+ activity,
1058
+ env,
1059
+ isPaused,
1060
+ }: {
1061
+ layout: MascotLayout;
1062
+ stats: PetStats;
1063
+ activity: PetActivity;
1064
+ env: Environment;
1065
+ isPaused: boolean;
1066
+ }) {
1067
+ const t = useTheme();
1068
+ const sideBySide = layout.mode === "split";
1069
+
1070
+ if (layout.mode === "tiny" || layout.mode === "compact") {
1071
+ return (
1072
+ <Box
1073
+ marginLeft={layout.leftPanel.inset}
1074
+ width={layout.available}
1075
+ flexDirection="column"
1076
+ >
1077
+ <CompactPetPanel
1078
+ stats={stats}
1079
+ activity={activity}
1080
+ env={env}
1081
+ isPaused={isPaused}
1082
+ width={Math.max(1, layout.available)}
1083
+ />
1084
+ </Box>
1085
+ );
1086
+ }
1087
+
1088
+ return (
1089
+ <Box width={layout.available}>
1090
+ <Box
1091
+ width={layout.available}
1092
+ borderStyle="round"
1093
+ borderColor={t.primary}
1094
+ paddingX={1}
1095
+ flexDirection={sideBySide ? "row" : "column"}
1096
+ alignItems={sideBySide ? "flex-start" : "center"}
1097
+ >
1098
+ <Box
1099
+ flexDirection="column"
1100
+ width={layout.leftPanel.width}
1101
+ alignItems="center"
1102
+ >
1103
+ <PetSceneReadout
1104
+ stats={stats}
1105
+ activity={activity}
1106
+ env={env}
1107
+ isPaused={isPaused}
1108
+ width={layout.leftPanel.width}
1109
+ />
1110
+ </Box>
1111
+ {sideBySide ? (
1112
+ <>
1113
+ <Box
1114
+ flexDirection="column"
1115
+ width={SPLIT_DIVIDER_WIDTH}
1116
+ flexShrink={0}
1117
+ >
1118
+ {PET_SPLIT_DIVIDER_ROWS.map((idx) => (
1119
+ <Text key={idx} color={t.primaryDim}>
1120
+ {" │ "}
1121
+ </Text>
1122
+ ))}
1123
+ </Box>
1124
+ <Box
1125
+ flexDirection="column"
1126
+ width={layout.rightColumn.width}
1127
+ paddingRight={RIGHT_COLUMN_PAD_RIGHT}
1128
+ >
1129
+ <Box marginLeft={layout.dealDesk.inset}>
1130
+ <PetStatsReadout
1131
+ stats={stats}
1132
+ activity={activity}
1133
+ env={env}
1134
+ width={Math.min(PET_STATS_MAX_WIDTH, layout.dealDesk.width)}
1135
+ />
1136
+ </Box>
1137
+ </Box>
1138
+ </>
1139
+ ) : (
1140
+ <Box marginTop={1} width={layout.tips.width} alignItems="center">
1141
+ <PetStatsReadout
1142
+ stats={stats}
1143
+ activity={activity}
1144
+ env={env}
1145
+ width={Math.max(COMPACT_PET_PANEL_MIN_WIDTH, layout.tips.width)}
1146
+ />
1147
+ </Box>
1148
+ )}
1149
+ </Box>
1150
+ </Box>
1151
+ );
1152
+ }
1153
+
783
1154
  export function MascotDashboard({
784
1155
  greeting,
785
1156
  width,
786
1157
  mood,
1158
+ mode = "normal",
1159
+ petStats,
1160
+ petActivity = "idle",
1161
+ petEnv = "office",
1162
+ petPaused = false,
787
1163
  bootProgress = 1,
788
1164
  state = INTRO_FRAMES[INTRO_FRAMES.length - 1]!,
789
1165
  bar = introBootBar(INTRO_FRAMES.length - 1, INTRO_FRAMES.length),
@@ -799,6 +1175,18 @@ export function MascotDashboard({
799
1175
  ? fixedDisplayRows(greeting, layout.copy.width, 2)
800
1176
  : [];
801
1177
 
1178
+ if (mode === "pet" && petStats) {
1179
+ return (
1180
+ <PetDashboard
1181
+ layout={layout}
1182
+ stats={petStats}
1183
+ activity={petActivity}
1184
+ env={petEnv}
1185
+ isPaused={petPaused}
1186
+ />
1187
+ );
1188
+ }
1189
+
802
1190
  if (layout.mode === "tiny") {
803
1191
  return (
804
1192
  <Box width={layout.available} flexDirection="column">
@@ -1,17 +1,18 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { memo, useEffect, useMemo, useState } from "react";
3
3
  import { getPetMood, type PetActivity, type PetStats } from "../pet/petState.ts";
4
- import { displayWidth, fitDisplayText } from "./graphemes.ts";
4
+ import { fitDisplayText } from "./graphemes.ts";
5
5
  import { useTheme } from "./ThemeContext.tsx";
6
6
  import { type Theme } from "./themes.ts";
7
7
 
8
- export const PET_PANEL_WIDTH = 36;
9
- export const PET_PANEL_ROWS = 22;
8
+ export const COMPACT_PET_PANEL_ROWS = 5;
9
+ export const TINY_PET_PANEL_ROWS = 1;
10
+ export const COMPACT_PET_PANEL_MIN_WIDTH = 48;
10
11
  export type Environment = "office" | "home" | "outdoors";
11
12
 
12
13
  const PANEL_BORDER_COLUMNS = 2;
13
14
  const PANEL_PADDING_COLUMNS = 2;
14
- const CONTENT = PET_PANEL_WIDTH - PANEL_BORDER_COLUMNS - PANEL_PADDING_COLUMNS;
15
+ const CONTENT = 32;
15
16
  const SPRITE_W = 8;
16
17
 
17
18
  const R_SKY = 0;
@@ -21,6 +22,7 @@ const R_DECO = 3;
21
22
  const R_SP0 = 4; // sprite occupies rows 4–9
22
23
  const R_FLOOR = 10;
23
24
  const SCENE_ROWS = 11;
25
+ export const PET_SCENE_WIDTH = CONTENT;
24
26
 
25
27
  // Fixed sprite X — no left/right walking
26
28
  const SPRITE_X: Record<PetActivity, number> = {
@@ -38,10 +40,6 @@ function place(base: string, text: string, x: number): string {
38
40
  const fit = text.slice(0, end - x);
39
41
  return base.slice(0, x) + fit + base.slice(end);
40
42
  }
41
- function pad(s: string, n: number): string {
42
- const fitted = fitDisplayText(s, n);
43
- return fitted + " ".repeat(Math.max(0, n - displayWidth(fitted)));
44
- }
45
43
 
46
44
  // Hoisted constant: floor pattern for home doesn't depend on frame.
47
45
  const HOME_CARPET = Array.from({ length: CONTENT }, (_, i) =>
@@ -426,36 +424,33 @@ function getStatusMsg(stats: PetStats, frame: number): string {
426
424
  return msgs[Math.floor(frame / 10) % msgs.length] ?? "Operational.";
427
425
  }
428
426
 
429
- // ─── stat bar ─────────────────────────────────────────────────────────────────
430
- function StatBarInner({
431
- label, value, barColor, labelColor, warnColor,
432
- }: {
433
- label: string; value: number; barColor: string; labelColor: string; warnColor: string;
434
- }) {
435
- const filled = Math.round((value / 100) * 14);
436
- const bar = "█".repeat(filled) + "░".repeat(14 - filled);
437
- const pct = String(Math.round(value)).padStart(3);
438
- const isLow = value < 25;
439
- return (
440
- <Text>
441
- <Text color={labelColor}>{pad(label, 6)}</Text>
442
- <Text color={isLow ? warnColor : barColor}>{bar}</Text>
443
- <Text color={isLow ? warnColor : labelColor}> {pct}%</Text>
444
- </Text>
445
- );
427
+ export function getPetStatusMessage(stats: PetStats, frame = 0): string {
428
+ return getStatusMsg(stats, frame);
446
429
  }
447
- const StatBar = memo(StatBarInner);
448
430
 
449
431
  // ─── component ────────────────────────────────────────────────────────────────
450
- interface PetPanelProps {
432
+ interface PetSceneProps {
451
433
  stats: PetStats;
452
434
  activity: PetActivity;
453
435
  env?: Environment;
454
436
  isPaused?: boolean;
455
437
  }
456
438
 
457
- function PetPanelView({ stats, activity, env = "office", isPaused = false }: PetPanelProps) {
458
- const t = useTheme();
439
+ interface CompactPetPanelProps extends PetSceneProps {
440
+ width: number;
441
+ }
442
+
443
+ function usePetFrame({
444
+ activity,
445
+ env,
446
+ isPaused,
447
+ dead,
448
+ }: {
449
+ activity: PetActivity;
450
+ env: Environment;
451
+ isPaused: boolean;
452
+ dead: boolean;
453
+ }) {
459
454
  const [frame, setFrame] = useState(0);
460
455
 
461
456
  useEffect(() => {
@@ -463,75 +458,167 @@ function PetPanelView({ stats, activity, env = "office", isPaused = false }: Pet
463
458
  }, [activity, env]);
464
459
 
465
460
  useEffect(() => {
466
- if (isPaused) return;
461
+ // Skip frame ticks when paused or when the pet has died — DeathScreen
462
+ // takes over the UI, no point burning a setInterval that mutates state
463
+ // nothing will read.
464
+ if (isPaused || dead) return;
467
465
  const id = setInterval(() => {
468
466
  setFrame((f) => f + 1);
469
467
  }, 800);
470
468
  return () => clearInterval(id);
471
- }, [isPaused]);
469
+ }, [dead, isPaused]);
470
+
471
+ return frame;
472
+ }
472
473
 
474
+ export function PetScene({
475
+ stats,
476
+ activity,
477
+ env = "office",
478
+ isPaused = false,
479
+ }: PetSceneProps) {
480
+ const t = useTheme();
481
+ const frame = usePetFrame({
482
+ activity,
483
+ env,
484
+ isPaused,
485
+ dead: stats.dead === true,
486
+ });
473
487
  const scene = useMemo(
474
488
  () => buildScene(activity, frame, stats, env),
475
489
  [activity, frame, stats, env],
476
490
  );
477
- const mood = useMemo(() => getPetMood(stats), [stats]);
478
- const status = useMemo(
479
- () => pad(`memo ${getStatusMsg(stats, frame)}`, CONTENT),
480
- [stats, frame],
491
+
492
+ return (
493
+ <Box flexDirection="column" width={PET_SCENE_WIDTH}>
494
+ {scene.map((row, i) => (
495
+ <Text key={i} color={rowColor(i, activity, frame, t)}>{row}</Text>
496
+ ))}
497
+ </Box>
498
+ );
499
+ }
500
+
501
+ function pct(value: number): string {
502
+ return `${Math.round(value)}%`;
503
+ }
504
+
505
+ const STAT_LEVEL_LABEL: Record<MsgLevel, string> = {
506
+ critical: "critical",
507
+ low: "low",
508
+ ok: "ok",
509
+ good: "good",
510
+ great: "peak",
511
+ };
512
+
513
+ interface CompactStatProfile {
514
+ hunger: MsgLevel;
515
+ happiness: MsgLevel;
516
+ energy: MsgLevel;
517
+ deals: MsgLevel;
518
+ }
519
+
520
+ function compactStatProfile(stats: PetStats): CompactStatProfile {
521
+ return {
522
+ hunger: statLevel(stats.hunger),
523
+ happiness: statLevel(stats.happiness),
524
+ energy: statLevel(stats.energy),
525
+ deals: statLevel(stats.deals),
526
+ };
527
+ }
528
+
529
+ interface WorstStat {
530
+ key: "hunger" | "happiness" | "energy" | "deals";
531
+ value: number;
532
+ }
533
+
534
+ function pickWorstStat(stats: PetStats): WorstStat {
535
+ const entries: WorstStat[] = [
536
+ { key: "hunger", value: stats.hunger },
537
+ { key: "happiness", value: stats.happiness },
538
+ { key: "energy", value: stats.energy },
539
+ { key: "deals", value: stats.deals },
540
+ ];
541
+ return entries.reduce((best, cur) =>
542
+ cur.value < best.value ? cur : best,
481
543
  );
482
- const title = fitDisplayText(`DREXLER DEAL DESK [${env}]`, CONTENT);
483
- const activityLabel = activity !== "idle" ? ` / ${activity}` : "";
484
- const moodLabel = `mood ${mood}`;
485
- const fittedMood = activityLabel
486
- ? fitDisplayText(moodLabel, Math.max(1, CONTENT - displayWidth(activityLabel)))
487
- : fitDisplayText(moodLabel, CONTENT);
488
- const fittedActivity = activityLabel && displayWidth(fittedMood) < CONTENT
489
- ? fitDisplayText(activityLabel, CONTENT - displayWidth(fittedMood))
490
- : "";
544
+ }
545
+
546
+ function CompactPetPanelView({
547
+ stats,
548
+ activity,
549
+ env = "office",
550
+ isPaused = false,
551
+ width,
552
+ }: CompactPetPanelProps) {
553
+ const t = useTheme();
554
+ const safeWidth = Math.max(1, width);
555
+
556
+ // Rotate the memo every 10s so the compact panel doesn't feel static.
557
+ // Paused panels lock to a single message (no decay-tick to refresh anyway).
558
+ const [tick, setTick] = useState(() => Math.floor(Date.now() / 10_000));
559
+ useEffect(() => {
560
+ if (isPaused) return;
561
+ const id = setInterval(() => {
562
+ setTick(Math.floor(Date.now() / 10_000));
563
+ }, 10_000);
564
+ return () => clearInterval(id);
565
+ }, [isPaused]);
566
+
567
+ const mood = getPetMood(stats);
568
+ const profile = compactStatProfile(stats);
569
+ const activityCopy = activity === "idle" ? env : `${env} / ${activity}`;
570
+ const title = "Drexler Pet Desk";
571
+ const statLine = [
572
+ `happy ${STAT_LEVEL_LABEL[profile.happiness]}`,
573
+ `hungr ${STAT_LEVEL_LABEL[profile.hunger]}`,
574
+ `enrgy ${STAT_LEVEL_LABEL[profile.energy]}`,
575
+ `deals ${STAT_LEVEL_LABEL[profile.deals]}`,
576
+ ].join(" · ");
577
+ const statusLine = `memo ${getStatusMsg(stats, tick)}`;
578
+
579
+ if (safeWidth < COMPACT_PET_PANEL_MIN_WIDTH) {
580
+ // Worst stat drives the ticker so an idle eye still catches a failing
581
+ // metric instead of a fixed happy/energy readout.
582
+ const worst = pickWorstStat(stats);
583
+ const worstLevel = statLevel(worst.value);
584
+ const accent = worstLevel === "critical" || worstLevel === "low"
585
+ ? t.warning
586
+ : t.primary;
587
+ return (
588
+ <Box width={safeWidth} flexShrink={1}>
589
+ <Text color={accent}>
590
+ {fitDisplayText(
591
+ `pet ${mood} · ${worst.key} ${pct(worst.value)} (${worstLevel})`,
592
+ safeWidth,
593
+ )}
594
+ </Text>
595
+ </Box>
596
+ );
597
+ }
598
+
599
+ const innerWidth = Math.max(1, safeWidth - PANEL_BORDER_COLUMNS - PANEL_PADDING_COLUMNS);
600
+ const header = `${title} [${activityCopy}]`;
491
601
 
492
602
  return (
493
603
  <Box
494
604
  flexDirection="column"
495
- width={PET_PANEL_WIDTH}
496
- flexShrink={0}
605
+ width={safeWidth}
606
+ flexShrink={1}
497
607
  borderStyle="round"
498
608
  borderColor={t.primaryDim}
609
+ paddingX={1}
499
610
  >
500
- <Box paddingX={1} justifyContent="center">
501
- <Text color={t.primary} bold>{title}</Text>
502
- </Box>
503
-
504
- <Box flexDirection="column" paddingX={1}>
505
- {scene.map((row, i) => (
506
- <Text key={i} color={rowColor(i, activity, frame, t)}>{row}</Text>
507
- ))}
508
- </Box>
509
-
510
- <Box paddingX={1}>
511
- <Text color={t.primaryDim}>{"─".repeat(CONTENT)}</Text>
512
- </Box>
513
-
514
- <Box flexDirection="column" paddingX={1}>
515
- <StatBar label="happy" value={stats.happiness} barColor={t.primary} labelColor={t.dim} warnColor={t.error} />
516
- <StatBar label="hungr" value={stats.hunger} barColor={t.primaryLight} labelColor={t.dim} warnColor={t.warning} />
517
- <StatBar label="enrgy" value={stats.energy} barColor={t.primaryLight} labelColor={t.dim} warnColor={t.warning} />
518
- <StatBar label="deals" value={stats.deals} barColor={t.primaryDim} labelColor={t.dim} warnColor={t.warning} />
611
+ <Box>
612
+ <Text color={t.primary} bold>{fitDisplayText(header, innerWidth)}</Text>
519
613
  </Box>
520
-
521
- <Box paddingX={1}>
522
- <Text color={t.primaryDim}>{"─".repeat(CONTENT)}</Text>
614
+ <Box>
615
+ <Text color={t.text}>{fitDisplayText(statLine, innerWidth)}</Text>
523
616
  </Box>
524
-
525
- <Box paddingX={1}>
526
- <Text color={t.dim}>{status}</Text>
527
- </Box>
528
-
529
- <Box paddingX={1}>
530
- <Text color={t.dim}>{fittedMood}</Text>
531
- {fittedActivity && <Text color={t.primaryDim}>{fittedActivity}</Text>}
617
+ <Box>
618
+ <Text color={t.dim}>{fitDisplayText(`${mood} · ${statusLine}`, innerWidth)}</Text>
532
619
  </Box>
533
620
  </Box>
534
621
  );
535
622
  }
536
623
 
537
- export const PetPanel = memo(PetPanelView);
624
+ export const CompactPetPanel = memo(CompactPetPanelView);