drexler 0.2.16 → 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/README.md CHANGED
@@ -89,9 +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
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.
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.
95
95
 
96
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.
97
97
 
@@ -136,6 +136,7 @@ Keyboard notes:
136
136
  | `/clear` | shred conversation history (system prompt pinned) |
137
137
  | `/exit` | meeting adjourned |
138
138
  | `/synergy` | run a rotating animated morale event |
139
+ | `/pet [on\|off]` | toggle pet dashboard mode for this session |
139
140
  | `/feed` | feed Drexler a deal memo |
140
141
  | `/play` | corporate synergy game with Drexler |
141
142
  | `/work` | Drexler grinds the deal pipeline |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.16",
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,6 +57,7 @@ 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
package/src/ui/App.tsx CHANGED
@@ -31,8 +31,6 @@ import {
31
31
  CompactPetPanel,
32
32
  COMPACT_PET_PANEL_MIN_WIDTH,
33
33
  COMPACT_PET_PANEL_ROWS,
34
- PetPanel,
35
- PET_PANEL_WIDTH,
36
34
  TINY_PET_PANEL_ROWS,
37
35
  type Environment,
38
36
  } from "./PetPanel.tsx";
@@ -93,10 +91,6 @@ import {
93
91
  import { getActiveTheme } from "./themes.ts";
94
92
 
95
93
  const TRANSCRIPT_CHROME_ROWS = 12;
96
- const PET_PANEL_MIN_MAIN_COLUMNS = 75;
97
- const PET_PANEL_GAP_COLUMNS = 1;
98
- const PET_PANEL_MIN_COLUMNS =
99
- PET_PANEL_WIDTH + PET_PANEL_GAP_COLUMNS + PET_PANEL_MIN_MAIN_COLUMNS;
100
94
 
101
95
  export function transcriptRowsForTerminalRows(rows: number): number {
102
96
  return Math.max(1, Math.min(24, rows - TRANSCRIPT_CHROME_ROWS));
@@ -198,6 +192,7 @@ interface AppProps {
198
192
  fetchFn?: FetchFn;
199
193
  greeting?: string;
200
194
  showIntroChrome?: boolean;
195
+ introInitiallyDone?: boolean;
201
196
  }
202
197
 
203
198
  export function App({
@@ -207,6 +202,7 @@ export function App({
207
202
  fetchFn,
208
203
  greeting,
209
204
  showIntroChrome = false,
205
+ introInitiallyDone = false,
210
206
  }: AppProps) {
211
207
  const { exit } = useApp();
212
208
  const { stdout } = useStdout();
@@ -228,36 +224,38 @@ export function App({
228
224
  const mode = useMemo(() => pickLayout(cols), [cols]);
229
225
  const chromeWidth = useMemo(() => Math.max(1, cols), [cols]);
230
226
  const isCompact = mode === "very-narrow";
231
- const [introDone, setIntroDone] = useState(false);
232
- const integratedIntro =
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
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
240
235
  ? cols >= COMPACT_PET_PANEL_MIN_WIDTH
241
236
  ? COMPACT_PET_PANEL_ROWS
242
237
  : TINY_PET_PANEL_ROWS
243
238
  : 0;
244
- const petPanelReservedWidth = showPetSidePanel
245
- ? PET_PANEL_WIDTH + PET_PANEL_GAP_COLUMNS
246
- : 0;
247
- const contentWidth = showPetSidePanel
248
- ? Math.max(1, cols - petPanelReservedWidth)
249
- : chromeWidth;
239
+ const contentWidth = chromeWidth;
250
240
  const contentInputWidth = Math.max(1, contentWidth);
251
241
  const contentStatusWidth = Math.max(1, contentInputWidth - 2);
252
- const introRowBudget =
253
- 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;
254
250
  const maxTranscriptRows = useMemo(
255
251
  () =>
256
252
  Math.max(
257
253
  1,
258
- transcriptRowsForTerminalRows(rows) - introRowBudget - compactPetRowBudget,
254
+ transcriptRowsForTerminalRows(rows) -
255
+ dashboardRowBudget -
256
+ fallbackPetRowBudget,
259
257
  ),
260
- [compactPetRowBudget, introRowBudget, rows],
258
+ [dashboardRowBudget, fallbackPetRowBudget, rows],
261
259
  );
262
260
 
263
261
  const [items, setItems] = useState<ChatItem[]>([]);
@@ -315,7 +313,7 @@ export function App({
315
313
  }, []);
316
314
  const intro = useIntroAnimation(
317
315
  chromeWidth,
318
- integratedIntro,
316
+ introActive,
319
317
  handleIntroComplete,
320
318
  );
321
319
 
@@ -391,6 +389,10 @@ export function App({
391
389
  },
392
390
  [],
393
391
  );
392
+ const setDashboardPetMode = useCallback((next: boolean) => {
393
+ petModeRef.current = next;
394
+ setPetMode(next);
395
+ }, []);
394
396
  const updatePetStats = useCallback((updater: (stats: PetStats) => PetStats) => {
395
397
  setPetStats((stats) => {
396
398
  const next = updater(stats);
@@ -687,6 +689,38 @@ export function App({
687
689
  runSynergyEvent();
688
690
  return;
689
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
+ }
690
724
  const isPetCommand =
691
725
  slashCommand === "/feed" ||
692
726
  slashCommand === "/play" ||
@@ -861,11 +895,11 @@ export function App({
861
895
  config,
862
896
  activeTheme,
863
897
  model,
864
- petStats,
865
898
  PET_MESSAGES,
866
899
  removeLastAssistantItem,
867
900
  runLLM,
868
901
  runSynergyEvent,
902
+ setDashboardPetMode,
869
903
  triggerExit,
870
904
  triggerPetActivity,
871
905
  updatePetStats,
@@ -1129,9 +1163,9 @@ export function App({
1129
1163
  messageCount={msgCount}
1130
1164
  status={headerStatus}
1131
1165
  compact={isCompact}
1132
- notice={!integratedIntro ? deskNotice ?? undefined : undefined}
1166
+ notice={!introActive ? deskNotice ?? undefined : undefined}
1133
1167
  maxWidth={Math.max(1, width)}
1134
- marginBottom={integratedIntro ? 0 : 1}
1168
+ marginBottom={introActive ? 0 : 1}
1135
1169
  />
1136
1170
  );
1137
1171
  const dealDeskHeader = renderDealDeskHeader(chromeWidth);
@@ -1148,24 +1182,26 @@ export function App({
1148
1182
  return (
1149
1183
  <ThemeProvider value={activeTheme}>
1150
1184
  <Box flexDirection="column">
1151
- {integratedIntro ? (
1185
+ {showFullDashboard && typeof greeting === "string" ? (
1152
1186
  <Box marginBottom={1}>
1153
1187
  <MascotDashboard
1154
1188
  greeting={greeting}
1155
1189
  width={chromeWidth}
1156
1190
  mood={mood}
1157
- bootProgress={intro.progress}
1158
- state={intro.state}
1159
- bar={intro.bar}
1160
- barColor={introBarColor}
1161
- 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}
1162
1201
  dealDesk={renderDealDeskHeader}
1163
1202
  />
1164
1203
  </Box>
1165
- ) : (
1166
- dealDeskHeader
1167
- )}
1168
- {showCompactPetPanel && (
1204
+ ) : showFallbackPetPanel ? (
1169
1205
  <Box marginBottom={1}>
1170
1206
  <CompactPetPanel
1171
1207
  stats={petStats}
@@ -1175,18 +1211,10 @@ export function App({
1175
1211
  width={chromeWidth}
1176
1212
  />
1177
1213
  </Box>
1214
+ ) : (
1215
+ dealDeskHeader
1178
1216
  )}
1179
1217
  <Box flexDirection="row" alignItems="flex-start">
1180
- {showPetSidePanel && (
1181
- <Box marginRight={PET_PANEL_GAP_COLUMNS}>
1182
- <PetPanel
1183
- stats={petStats}
1184
- activity={petActivity}
1185
- env={petEnv}
1186
- isPaused={isBusy}
1187
- />
1188
- </Box>
1189
- )}
1190
1218
  <Box flexDirection="column" flexGrow={1}>
1191
1219
  <TranscriptViewport
1192
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,12 +1,10 @@
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;
10
8
  export const COMPACT_PET_PANEL_ROWS = 5;
11
9
  export const TINY_PET_PANEL_ROWS = 1;
12
10
  export const COMPACT_PET_PANEL_MIN_WIDTH = 48;
@@ -14,9 +12,8 @@ export type Environment = "office" | "home" | "outdoors";
14
12
 
15
13
  const PANEL_BORDER_COLUMNS = 2;
16
14
  const PANEL_PADDING_COLUMNS = 2;
17
- const CONTENT = PET_PANEL_WIDTH - PANEL_BORDER_COLUMNS - PANEL_PADDING_COLUMNS;
15
+ const CONTENT = 32;
18
16
  const SPRITE_W = 8;
19
- const DIVIDER_LINE = "─".repeat(CONTENT);
20
17
 
21
18
  const R_SKY = 0;
22
19
  const R_BGA = 1;
@@ -25,6 +22,7 @@ const R_DECO = 3;
25
22
  const R_SP0 = 4; // sprite occupies rows 4–9
26
23
  const R_FLOOR = 10;
27
24
  const SCENE_ROWS = 11;
25
+ export const PET_SCENE_WIDTH = CONTENT;
28
26
 
29
27
  // Fixed sprite X — no left/right walking
30
28
  const SPRITE_X: Record<PetActivity, number> = {
@@ -42,10 +40,6 @@ function place(base: string, text: string, x: number): string {
42
40
  const fit = text.slice(0, end - x);
43
41
  return base.slice(0, x) + fit + base.slice(end);
44
42
  }
45
- function pad(s: string, n: number): string {
46
- const fitted = fitDisplayText(s, n);
47
- return fitted + " ".repeat(Math.max(0, n - displayWidth(fitted)));
48
- }
49
43
 
50
44
  // Hoisted constant: floor pattern for home doesn't depend on frame.
51
45
  const HOME_CARPET = Array.from({ length: CONTENT }, (_, i) =>
@@ -430,40 +424,33 @@ function getStatusMsg(stats: PetStats, frame: number): string {
430
424
  return msgs[Math.floor(frame / 10) % msgs.length] ?? "Operational.";
431
425
  }
432
426
 
433
- // ─── stat bar ─────────────────────────────────────────────────────────────────
434
- function StatBarInner({
435
- label, value, barColor, labelColor, warnColor,
436
- }: {
437
- label: string; value: number; barColor: string; labelColor: string; warnColor: string;
438
- }) {
439
- const filled = Math.round((value / 100) * 14);
440
- const bar = "█".repeat(filled) + "░".repeat(14 - filled);
441
- const pct = String(Math.round(value)).padStart(3);
442
- const isLow = value < 25;
443
- return (
444
- <Text>
445
- <Text color={labelColor}>{pad(label, 6)}</Text>
446
- <Text color={isLow ? warnColor : barColor}>{bar}</Text>
447
- <Text color={isLow ? warnColor : labelColor}> {pct}%</Text>
448
- </Text>
449
- );
427
+ export function getPetStatusMessage(stats: PetStats, frame = 0): string {
428
+ return getStatusMsg(stats, frame);
450
429
  }
451
- const StatBar = memo(StatBarInner);
452
430
 
453
431
  // ─── component ────────────────────────────────────────────────────────────────
454
- interface PetPanelProps {
432
+ interface PetSceneProps {
455
433
  stats: PetStats;
456
434
  activity: PetActivity;
457
435
  env?: Environment;
458
436
  isPaused?: boolean;
459
437
  }
460
438
 
461
- interface CompactPetPanelProps extends PetPanelProps {
439
+ interface CompactPetPanelProps extends PetSceneProps {
462
440
  width: number;
463
441
  }
464
442
 
465
- function PetPanelView({ stats, activity, env = "office", isPaused = false }: PetPanelProps) {
466
- const t = useTheme();
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
+ }) {
467
454
  const [frame, setFrame] = useState(0);
468
455
 
469
456
  useEffect(() => {
@@ -474,79 +461,43 @@ function PetPanelView({ stats, activity, env = "office", isPaused = false }: Pet
474
461
  // Skip frame ticks when paused or when the pet has died — DeathScreen
475
462
  // takes over the UI, no point burning a setInterval that mutates state
476
463
  // nothing will read.
477
- if (isPaused || stats.dead === true) return;
464
+ if (isPaused || dead) return;
478
465
  const id = setInterval(() => {
479
466
  setFrame((f) => f + 1);
480
467
  }, 800);
481
468
  return () => clearInterval(id);
482
- }, [isPaused, stats.dead]);
469
+ }, [dead, isPaused]);
483
470
 
471
+ return frame;
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
+ });
484
487
  const scene = useMemo(
485
488
  () => buildScene(activity, frame, stats, env),
486
489
  [activity, frame, stats, env],
487
490
  );
488
- const mood = useMemo(() => getPetMood(stats), [stats]);
489
- const status = useMemo(
490
- () => pad(`memo ${getStatusMsg(stats, frame)}`, CONTENT),
491
- [stats, frame],
492
- );
493
- const title = fitDisplayText(`DREXLER PET DESK [${env}]`, CONTENT);
494
- const activityLabel = activity !== "idle" ? ` / ${activity}` : "";
495
- const moodLabel = `mood ${mood}`;
496
- const fittedMood = activityLabel
497
- ? fitDisplayText(moodLabel, Math.max(1, CONTENT - displayWidth(activityLabel)))
498
- : fitDisplayText(moodLabel, CONTENT);
499
- const fittedActivity = activityLabel && displayWidth(fittedMood) < CONTENT
500
- ? fitDisplayText(activityLabel, CONTENT - displayWidth(fittedMood))
501
- : "";
502
491
 
503
492
  return (
504
- <Box
505
- flexDirection="column"
506
- width={PET_PANEL_WIDTH}
507
- flexShrink={0}
508
- borderStyle="round"
509
- borderColor={t.primaryDim}
510
- >
511
- <Box paddingX={1} justifyContent="center">
512
- <Text color={t.primary} bold>{title}</Text>
513
- </Box>
514
-
515
- <Box flexDirection="column" paddingX={1}>
516
- {scene.map((row, i) => (
517
- <Text key={i} color={rowColor(i, activity, frame, t)}>{row}</Text>
518
- ))}
519
- </Box>
520
-
521
- <Box paddingX={1}>
522
- <Text color={t.primaryDim}>{DIVIDER_LINE}</Text>
523
- </Box>
524
-
525
- <Box flexDirection="column" paddingX={1}>
526
- <StatBar label="happy" value={stats.happiness} barColor={t.primary} labelColor={t.dim} warnColor={t.error} />
527
- <StatBar label="hungr" value={stats.hunger} barColor={t.primaryLight} labelColor={t.dim} warnColor={t.warning} />
528
- <StatBar label="enrgy" value={stats.energy} barColor={t.primaryLight} labelColor={t.dim} warnColor={t.warning} />
529
- <StatBar label="deals" value={stats.deals} barColor={t.primaryDim} labelColor={t.dim} warnColor={t.warning} />
530
- </Box>
531
-
532
- <Box paddingX={1}>
533
- <Text color={t.primaryDim}>{DIVIDER_LINE}</Text>
534
- </Box>
535
-
536
- <Box paddingX={1}>
537
- <Text color={t.dim}>{status}</Text>
538
- </Box>
539
-
540
- <Box paddingX={1}>
541
- <Text color={t.dim}>{fittedMood}</Text>
542
- {fittedActivity && <Text color={t.primaryDim}>{fittedActivity}</Text>}
543
- </Box>
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
+ ))}
544
497
  </Box>
545
498
  );
546
499
  }
547
500
 
548
- export const PetPanel = memo(PetPanelView);
549
-
550
501
  function pct(value: number): string {
551
502
  return `${Math.round(value)}%`;
552
503
  }
@@ -580,7 +531,7 @@ interface WorstStat {
580
531
  value: number;
581
532
  }
582
533
 
583
- export function pickWorstStat(stats: PetStats): WorstStat {
534
+ function pickWorstStat(stats: PetStats): WorstStat {
584
535
  const entries: WorstStat[] = [
585
536
  { key: "hunger", value: stats.hunger },
586
537
  { key: "happiness", value: stats.happiness },