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 +13 -0
- package/README.md +12 -1
- package/package.json +1 -1
- package/src/commands.ts +3 -0
- package/src/index.ts +3 -0
- package/src/ui/App.tsx +101 -49
- package/src/ui/MascotIntro.tsx +388 -0
- package/src/ui/PetPanel.tsx +164 -77
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
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
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 =
|
|
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
|
|
242
|
-
|
|
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) -
|
|
254
|
+
transcriptRowsForTerminalRows(rows) -
|
|
255
|
+
dashboardRowBudget -
|
|
256
|
+
fallbackPetRowBudget,
|
|
248
257
|
),
|
|
249
|
-
[
|
|
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
|
|
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
|
-
}, [
|
|
442
|
+
}, [updatePetStats]);
|
|
428
443
|
|
|
429
444
|
// Death detection
|
|
430
445
|
useEffect(() => {
|
|
431
|
-
if (
|
|
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,
|
|
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={!
|
|
1166
|
+
notice={!introActive ? deskNotice ?? undefined : undefined}
|
|
1120
1167
|
maxWidth={Math.max(1, width)}
|
|
1121
|
-
marginBottom={
|
|
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
|
-
{
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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}
|
package/src/ui/MascotIntro.tsx
CHANGED
|
@@ -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">
|
package/src/ui/PetPanel.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
9
|
-
export const
|
|
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 =
|
|
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
|
-
|
|
430
|
-
|
|
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
|
|
432
|
+
interface PetSceneProps {
|
|
451
433
|
stats: PetStats;
|
|
452
434
|
activity: PetActivity;
|
|
453
435
|
env?: Environment;
|
|
454
436
|
isPaused?: boolean;
|
|
455
437
|
}
|
|
456
438
|
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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={
|
|
496
|
-
flexShrink={
|
|
605
|
+
width={safeWidth}
|
|
606
|
+
flexShrink={1}
|
|
497
607
|
borderStyle="round"
|
|
498
608
|
borderColor={t.primaryDim}
|
|
609
|
+
paddingX={1}
|
|
499
610
|
>
|
|
500
|
-
<Box
|
|
501
|
-
<Text color={t.primary} bold>{
|
|
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
|
-
|
|
522
|
-
<Text color={t.primaryDim}>{"─".repeat(CONTENT)}</Text>
|
|
614
|
+
<Box>
|
|
615
|
+
<Text color={t.text}>{fitDisplayText(statLine, innerWidth)}</Text>
|
|
523
616
|
</Box>
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
624
|
+
export const CompactPetPanel = memo(CompactPetPanelView);
|