drexler 0.2.13 → 0.2.15

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/src/ui/App.tsx CHANGED
@@ -1,8 +1,42 @@
1
1
  import { Box, Text, useApp, useInput, useStdout } from "ink";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ accrueLifetimeDeals,
5
+ actionCooldown,
6
+ applyFeed,
7
+ applyMinuteDecay,
8
+ applyName,
9
+ applyPlay,
10
+ applyPraise,
11
+ applyRest,
12
+ applyVibe,
13
+ applyWork,
14
+ formatCooldownRemaining,
15
+ formatTenure,
16
+ getPetMood,
17
+ getPetRank,
18
+ isPetDead,
19
+ loadPetState,
20
+ petTenureMs,
21
+ rankLabel,
22
+ sanitizePetName,
23
+ savePetState,
24
+ stampAction,
25
+ type PetActionKey,
26
+ type PetActivity,
27
+ type PetStats,
28
+ } from "../pet/petState.ts";
29
+ import { DeathScreen } from "./DeathScreen.tsx";
30
+ import {
31
+ PetPanel,
32
+ PET_PANEL_ROWS,
33
+ PET_PANEL_WIDTH,
34
+ type Environment,
35
+ } from "./PetPanel.tsx";
3
36
  import {
4
37
  dispatch,
5
38
  filterPaletteByPrefix,
39
+ isArgumentParentCommand,
6
40
  isSlash,
7
41
  type CommandAction,
8
42
  } from "../commands.ts";
@@ -23,7 +57,6 @@ import {
23
57
  WITTICISMS,
24
58
  } from "../sayings.ts";
25
59
  import { type Config } from "../types.ts";
26
- import { THEME_NAMES } from "../types.ts";
27
60
  import { CommandPalette } from "./CommandPalette.tsx";
28
61
  import { DealDeskHeader } from "./DealDeskHeader.tsx";
29
62
  import {
@@ -34,7 +67,11 @@ import {
34
67
  insertAtCursor,
35
68
  } from "./graphemes.ts";
36
69
  import { InputBox } from "./InputBox.tsx";
37
- import { MascotDashboard } from "./MascotIntro.tsx";
70
+ import {
71
+ introPhaseColor,
72
+ MascotDashboard,
73
+ useIntroAnimation,
74
+ } from "./MascotIntro.tsx";
38
75
  import { StreamingMessage } from "./Message.tsx";
39
76
  import { Spinner } from "./Spinner.tsx";
40
77
  import { StatusBar } from "./StatusBar.tsx";
@@ -46,10 +83,18 @@ import {
46
83
  type SynergyEventDefinition,
47
84
  } from "./SynergyEvent.tsx";
48
85
  import { ThemeProvider } from "./ThemeContext.tsx";
49
- import { TranscriptViewport } from "./TranscriptViewport.tsx";
50
- import { getActiveTheme, THEMES } from "./themes.ts";
86
+ import {
87
+ estimateTranscriptRows,
88
+ TranscriptViewport,
89
+ } from "./TranscriptViewport.tsx";
90
+ import { getActiveTheme } from "./themes.ts";
51
91
 
52
92
  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;
53
98
 
54
99
  export function transcriptRowsForTerminalRows(rows: number): number {
55
100
  return Math.max(1, Math.min(24, rows - TRANSCRIPT_CHROME_ROWS));
@@ -58,15 +103,22 @@ export function transcriptRowsForTerminalRows(rows: number): number {
58
103
  export function nextTranscriptScrollOffset({
59
104
  current,
60
105
  itemCount,
106
+ totalRows,
107
+ visibleRows,
61
108
  direction,
62
109
  step = 3,
63
110
  }: {
64
111
  current: number;
65
- itemCount: number;
112
+ itemCount?: number;
113
+ totalRows?: number;
114
+ visibleRows?: number;
66
115
  direction: "older" | "newer";
67
116
  step?: number;
68
117
  }): number {
69
- const maxOffset = Math.max(0, itemCount - 1);
118
+ const maxOffset =
119
+ totalRows !== undefined
120
+ ? Math.max(0, totalRows - Math.max(1, visibleRows ?? 1))
121
+ : Math.max(0, (itemCount ?? 0) - 1);
70
122
  if (direction === "older") {
71
123
  return Math.min(maxOffset, current + step);
72
124
  }
@@ -79,6 +131,46 @@ export function shouldRemoveVisibleAssistantForAction(
79
131
  return action.type === "regenerate" && action.removedAssistant;
80
132
  }
81
133
 
134
+ export interface HistoryNavState {
135
+ historyIdx: number | null;
136
+ draft: { value: string; cursor: number };
137
+ historyDraft: { value: string; cursor: number } | null;
138
+ }
139
+
140
+ export function historyNavStep(
141
+ state: HistoryNavState,
142
+ history: readonly string[],
143
+ direction: "up" | "down",
144
+ ): HistoryNavState {
145
+ if (direction === "up") {
146
+ if (history.length === 0) return state;
147
+ const snapshot =
148
+ state.historyIdx === null ? { ...state.draft } : state.historyDraft;
149
+ const idx =
150
+ state.historyIdx === null
151
+ ? history.length - 1
152
+ : Math.max(0, state.historyIdx - 1);
153
+ const entry = history[idx] ?? "";
154
+ return {
155
+ historyIdx: idx,
156
+ draft: { value: entry, cursor: graphemeLength(entry) },
157
+ historyDraft: snapshot,
158
+ };
159
+ }
160
+ if (state.historyIdx === null) return state;
161
+ const next = state.historyIdx + 1;
162
+ if (next >= history.length) {
163
+ const restored = state.historyDraft ?? { value: "", cursor: 0 };
164
+ return { historyIdx: null, draft: restored, historyDraft: null };
165
+ }
166
+ const entry = history[next] ?? "";
167
+ return {
168
+ historyIdx: next,
169
+ draft: { value: entry, cursor: graphemeLength(entry) },
170
+ historyDraft: state.historyDraft,
171
+ };
172
+ }
173
+
82
174
  function pick<T>(arr: readonly T[]): T {
83
175
  if (arr.length === 0) {
84
176
  throw new Error("pick called on empty array");
@@ -132,12 +224,20 @@ export function App({
132
224
  };
133
225
  }, [stdout]);
134
226
  const mode = useMemo(() => pickLayout(cols), [cols]);
135
- const inputWidth = useMemo(() => Math.max(1, cols), [cols]);
136
227
  const chromeWidth = useMemo(() => Math.max(1, cols), [cols]);
137
- const statusBarWidth = inputWidth;
138
228
  const isCompact = mode === "very-narrow";
139
229
  const integratedIntro =
140
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
235
+ : 0;
236
+ const contentWidth = showPetPanel
237
+ ? Math.max(1, cols - petPanelReservedWidth)
238
+ : chromeWidth;
239
+ const contentInputWidth = Math.max(1, contentWidth);
240
+ const contentStatusWidth = Math.max(1, contentInputWidth - 2);
141
241
  const introRowBudget =
142
242
  integratedIntro ? (chromeWidth >= 112 ? 14 : chromeWidth >= 72 ? 26 : 6) : 0;
143
243
  const maxTranscriptRows = useMemo(
@@ -193,17 +293,153 @@ export function App({
193
293
  const [witticism, setWitticism] = useState<string>(pick(WITTICISMS));
194
294
  const [model, setModel] = useState<string>(config.model);
195
295
  const [msgCount, setMsgCount] = useState<number>(0);
196
- const [tokenCount, setTokenCount] = useState<number>(
197
- conversation.approximateTokens(),
198
- );
199
- const [lastLatencyMs, setLastLatencyMs] = useState<number | null>(null);
200
- const [fallbackModel, setFallbackModel] = useState<string | null>(null);
201
296
  const [deskStatus, setDeskStatus] = useState<"idle" | "error">("idle");
202
297
  const [deskNotice, setDeskNotice] = useState<string | null>(null);
203
298
  const [history, setHistory] = useState<string[]>([]);
204
299
  const [historyIdx, setHistoryIdx] = useState<number | null>(null);
205
300
  const [paletteIdx, setPaletteIdx] = useState(0);
206
301
  const [scrollOffset, setScrollOffset] = useState(0);
302
+ const intro = useIntroAnimation(chromeWidth, integratedIntro);
303
+
304
+ const [petStats, setPetStats] = useState<PetStats>(() => loadPetState());
305
+ const [petActivity, setPetActivity] = useState<PetActivity>("idle");
306
+ const [isDead, setIsDead] = useState(false);
307
+ const [deathReason, setDeathReason] = useState("energy");
308
+ const [deathVariant, setDeathVariant] = useState(0);
309
+ const petStatsRef = useRef<PetStats>(petStats);
310
+ const petActivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
311
+ const petDecayTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
312
+
313
+ const petEnv = useMemo((): Environment => {
314
+ const h = new Date().getHours();
315
+ if (h >= 9 && h < 18) return "office";
316
+ if (h >= 6 && h < 23) return "home";
317
+ return "outdoors";
318
+ }, []);
319
+
320
+ const PET_MESSAGES = useMemo(() => ({
321
+ feed: [
322
+ "Drexler receives deal memo. Hunger: satisfied. Pipeline: expanding.",
323
+ "Drexler consumes quarterly report. Fortifying.",
324
+ "Deal deck delivered. Drexler is replenished.",
325
+ "Nutrition acquired via term sheet. Excellent.",
326
+ "Drexler ingests synergy bundle. Caloric intake: maximized.",
327
+ "Pipeline refueled. Drexler gives brief nod of approval.",
328
+ ],
329
+ play: [
330
+ "Drexler engages in corporate synergy games. Morale: elevated.",
331
+ "Drexler attempts leisure. Unfamiliar but effective.",
332
+ "Golf simulation initiated. Handicap: nonexistent.",
333
+ "Corporate retreat protocols engaged. Team building: successful.",
334
+ "Drexler plays. Competitors watch nervously.",
335
+ "Recreational time allocated. ROI: unclear but positive.",
336
+ ],
337
+ work: [
338
+ "Drexler retreats to deal desk. Pipeline throughput: increasing.",
339
+ "Grind mode initiated. Coffee consumed preemptively.",
340
+ "Drexler is doing the work. Others take note.",
341
+ "Deal origination in progress. Board is watching.",
342
+ "Drexler enters flow state. Productivity: exceptional.",
343
+ "All-nighter commenced. Regrets: minimal.",
344
+ ],
345
+ praise: [
346
+ "Drexler acknowledges commendation. Briefly.",
347
+ "Praise received. Filed under: expected.",
348
+ "Drexler nods. One singular nod.",
349
+ "Affirmation noted. Drexler remains unmoved. Mostly.",
350
+ "Kind words processed. Ego: appropriately inflated.",
351
+ "Drexler accepts compliment with characteristic restraint.",
352
+ ],
353
+ rest: [
354
+ "Drexler retires briefly. Strategic recharge in progress.",
355
+ "Under-desk nap initiated. Do not disturb.",
356
+ "Drexler powers down. Temporarily.",
357
+ "Rest mode engaged. Energy recovery: imminent.",
358
+ "Strategic downtime commenced. Drexler will return stronger.",
359
+ "Drexler sleeps. Dreams of closed deals.",
360
+ ],
361
+ }), []);
362
+
363
+ const triggerPetActivity = useCallback(
364
+ (activity: PetActivity, durationMs: number) => {
365
+ if (petActivityTimerRef.current !== null) {
366
+ clearTimeout(petActivityTimerRef.current);
367
+ }
368
+ setPetActivity(activity);
369
+ petActivityTimerRef.current = setTimeout(() => {
370
+ setPetActivity("idle");
371
+ petActivityTimerRef.current = null;
372
+ }, durationMs);
373
+ },
374
+ [],
375
+ );
376
+ const updatePetStats = useCallback((updater: (stats: PetStats) => PetStats) => {
377
+ setPetStats((stats) => {
378
+ const next = updater(stats);
379
+ petStatsRef.current = next;
380
+ savePetState(next);
381
+ return next;
382
+ });
383
+ }, []);
384
+ const applyPetAction = useCallback(
385
+ (action: PetActionKey, mutator: (stats: PetStats) => PetStats) => {
386
+ const before = getPetRank(petStatsRef.current);
387
+ updatePetStats((s) => {
388
+ const next = stampAction(mutator(s), action);
389
+ return accrueLifetimeDeals(next, action);
390
+ });
391
+ const after = getPetRank(petStatsRef.current);
392
+ if (after !== before) {
393
+ addItem(
394
+ "system",
395
+ `PROMOTION MEMO: Drexler ranked up to ${rankLabel(after)}. Reward: more meetings.`,
396
+ );
397
+ }
398
+ },
399
+ [updatePetStats, addItem],
400
+ );
401
+
402
+ useEffect(() => {
403
+ petStatsRef.current = petStats;
404
+ }, [petStats]);
405
+
406
+ // Real-time stat decay matches the offline per-hour decay rate.
407
+ useEffect(() => {
408
+ if (!showPetPanel) {
409
+ return () => {
410
+ savePetState(petStatsRef.current);
411
+ };
412
+ }
413
+ petDecayTimerRef.current = setInterval(() => {
414
+ updatePetStats(applyMinuteDecay);
415
+ }, 60_000);
416
+ return () => {
417
+ if (petDecayTimerRef.current !== null) {
418
+ clearInterval(petDecayTimerRef.current);
419
+ petDecayTimerRef.current = null;
420
+ }
421
+ if (petActivityTimerRef.current !== null) {
422
+ clearTimeout(petActivityTimerRef.current);
423
+ petActivityTimerRef.current = null;
424
+ }
425
+ savePetState(petStatsRef.current);
426
+ };
427
+ }, [showPetPanel, updatePetStats]);
428
+
429
+ // Death detection
430
+ useEffect(() => {
431
+ if (!showPetPanel || isDead || !isPetDead(petStats)) return;
432
+ const reason =
433
+ petStats.hunger <= 0 ? "hunger" :
434
+ petStats.happiness <= 0 ? "happiness" : "energy";
435
+ setDeathReason(reason);
436
+ setDeathVariant(Math.floor(Math.random() * 5));
437
+ setIsDead(true);
438
+ const deadStats = { ...petStats, dead: true };
439
+ petStatsRef.current = deadStats;
440
+ savePetState(deadStats);
441
+ exitTimerRef.current = setTimeout(() => exit(), 5000);
442
+ }, [petStats, showPetPanel, isDead, exit]);
207
443
 
208
444
  const paletteItems = useMemo(() => filterPaletteByPrefix(input), [input]);
209
445
  const paletteOpen = paletteItems.length > 0;
@@ -215,23 +451,17 @@ export function App({
215
451
  setScrollOffset(0);
216
452
  }, [items.length]);
217
453
 
218
- useEffect(() => {
219
- setTokenCount(conversation.approximateTokens());
220
- }, [conversation, msgCount]);
221
-
222
- const themeName = useMemo(() => {
223
- const active = getActiveTheme();
224
- return (
225
- THEME_NAMES.find((name) => THEMES[name] === active) ??
226
- config.theme ??
227
- "apollo"
228
- );
229
- }, [activeTheme, config.theme]);
230
-
454
+ const visibleTranscriptRows = synergyEvent
455
+ ? Math.max(1, maxTranscriptRows - synergyEventRows(contentWidth, isCompact))
456
+ : maxTranscriptRows;
457
+ const estimatedTranscriptRows = useMemo(
458
+ () => estimateTranscriptRows(items, isCompact, contentWidth),
459
+ [items, isCompact, contentWidth],
460
+ );
231
461
  const scrollHint = useMemo(() => {
232
- if (items.length <= maxTranscriptRows) return undefined;
462
+ if (estimatedTranscriptRows <= visibleTranscriptRows) return undefined;
233
463
  return scrollOffset > 0 ? "PageDown newer" : "PageUp scrollback";
234
- }, [items.length, maxTranscriptRows, scrollOffset]);
464
+ }, [estimatedTranscriptRows, visibleTranscriptRows, scrollOffset]);
235
465
 
236
466
  // throttle streaming updates so React doesn't re-render every token
237
467
  const streamBufRef = useRef("");
@@ -244,6 +474,7 @@ export function App({
244
474
  const exitingRef = useRef(false);
245
475
  const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
246
476
  const synergyTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
477
+ const historyDraftRef = useRef<{ value: string; cursor: number } | null>(null);
247
478
  const flushStream = useCallback(() => {
248
479
  if (!mountedRef.current) return;
249
480
  setStreaming(streamBufRef.current);
@@ -255,6 +486,7 @@ export function App({
255
486
  if (exitingRef.current) return;
256
487
  exitingRef.current = true;
257
488
  abortRef.current?.abort();
489
+ savePetState(petStatsRef.current);
258
490
  if (streamTimerRef.current !== null) {
259
491
  clearTimeout(streamTimerRef.current);
260
492
  streamTimerRef.current = null;
@@ -328,12 +560,10 @@ export function App({
328
560
  if (requestInFlightRef.current) return;
329
561
  requestInFlightRef.current = true;
330
562
  setRequestInFlight(true);
331
- const startedAt = Date.now();
332
563
  try {
333
564
  setThinking(pick(THINKING_LINES));
334
565
  setDeskStatus("idle");
335
566
  setDeskNotice(null);
336
- setFallbackModel(null);
337
567
  streamBufRef.current = "";
338
568
  setStreaming(null);
339
569
  let firstToken = true;
@@ -384,7 +614,6 @@ export function App({
384
614
  }
385
615
  setThinking(null);
386
616
  setStreaming(null);
387
- setLastLatencyMs(Date.now() - startedAt);
388
617
  if (cancelledRef.current) {
389
618
  cancelledRef.current = false;
390
619
  if (result?.content) {
@@ -400,7 +629,6 @@ export function App({
400
629
  if (result.fellBack) {
401
630
  addItem("system", `(fell back to ${result.modelUsed})`);
402
631
  notices.push(`fallback ${result.modelUsed}`);
403
- setFallbackModel(result.modelUsed);
404
632
  }
405
633
  if (detectPersonaDrift(result.content)) {
406
634
  addItem("system", `(persona drift detected — model used 'I')`);
@@ -420,7 +648,6 @@ export function App({
420
648
  setDeskNotice(result?.error ?? "stream error");
421
649
  }
422
650
  setMsgCount(conversation.length);
423
- setTokenCount(conversation.approximateTokens());
424
651
  setWitticism(pick(WITTICISMS));
425
652
  } finally {
426
653
  requestInFlightRef.current = false;
@@ -439,6 +666,134 @@ export function App({
439
666
 
440
667
  const handleSlashWithMutation = useCallback(
441
668
  async (line: string): Promise<void> => {
669
+ const lower = line.toLowerCase().trim();
670
+ const [slashCommand = lower] = lower.split(/\s+/, 1);
671
+
672
+ // Pet commands — handled before dispatch so they don't hit the unknown-command path
673
+ if (lower === "/synergy") {
674
+ runSynergyEvent();
675
+ return;
676
+ }
677
+ const isPetCommand =
678
+ slashCommand === "/feed" ||
679
+ slashCommand === "/play" ||
680
+ slashCommand === "/work" ||
681
+ slashCommand === "/praise" ||
682
+ slashCommand === "/rest" ||
683
+ slashCommand === "/vibe";
684
+ if (isPetCommand && isDead) {
685
+ addItem("system", "Drexler is in HR. Restructuring paperwork pending — try again after revival.");
686
+ return;
687
+ }
688
+ const cooldownAction: PetActionKey | null =
689
+ slashCommand === "/feed" ? "feed"
690
+ : slashCommand === "/play" ? "play"
691
+ : slashCommand === "/work" ? "work"
692
+ : slashCommand === "/praise" ? "praise"
693
+ : slashCommand === "/rest" ? "rest"
694
+ : slashCommand === "/vibe" ? "vibe"
695
+ : null;
696
+ if (cooldownAction !== null) {
697
+ const cd = actionCooldown(petStatsRef.current, cooldownAction);
698
+ if (!cd.ok) {
699
+ addItem(
700
+ "system",
701
+ `Drexler ${cooldownAction === "feed" ? "just ate" : cooldownAction === "play" ? "just played" : cooldownAction === "work" ? "just worked" : cooldownAction === "praise" ? "just got praised" : cooldownAction === "rest" ? "just rested" : "just vibed"}. Wait ${formatCooldownRemaining(cd.remainingMs)} before the next attempt. Drexler resents micromanagement.`,
702
+ );
703
+ return;
704
+ }
705
+ }
706
+ if (slashCommand === "/feed") {
707
+ applyPetAction("feed", applyFeed);
708
+ triggerPetActivity("eating", 3500);
709
+ addItem("system", pick(PET_MESSAGES.feed));
710
+ return;
711
+ }
712
+ if (slashCommand === "/play") {
713
+ applyPetAction("play", applyPlay);
714
+ triggerPetActivity("playing", 4000);
715
+ addItem("system", pick(PET_MESSAGES.play));
716
+ return;
717
+ }
718
+ if (slashCommand === "/work") {
719
+ applyPetAction("work", applyWork);
720
+ triggerPetActivity("working", 5000);
721
+ addItem("system", pick(PET_MESSAGES.work));
722
+ return;
723
+ }
724
+ if (slashCommand === "/praise") {
725
+ applyPetAction("praise", applyPraise);
726
+ triggerPetActivity("praised", 3000);
727
+ addItem("system", pick(PET_MESSAGES.praise));
728
+ return;
729
+ }
730
+ if (slashCommand === "/rest") {
731
+ applyPetAction("rest", applyRest);
732
+ triggerPetActivity("sleeping", 5000);
733
+ addItem("system", pick(PET_MESSAGES.rest));
734
+ return;
735
+ }
736
+ if (slashCommand === "/vibe") {
737
+ const result = applyVibe(petStatsRef.current);
738
+ applyPetAction("vibe", () => result.stats);
739
+ triggerPetActivity("vibing", 3500);
740
+ addItem("system", result.message);
741
+ return;
742
+ }
743
+ if (slashCommand === "/name") {
744
+ const arg = line.slice("/name".length).trim();
745
+ if (arg.length === 0) {
746
+ const current = petStatsRef.current.name;
747
+ addItem(
748
+ "system",
749
+ current
750
+ ? `Drexler's pet name on file: "${current}". /name <new> to reassign.`
751
+ : "No pet name on file. /name <name> to issue corporate identity.",
752
+ );
753
+ return;
754
+ }
755
+ const cleaned = sanitizePetName(arg);
756
+ if (cleaned.length === 0) {
757
+ addItem(
758
+ "system",
759
+ "Drexler refuses unprintable identity. Pick letters, numbers, spaces, dots, or apostrophes (≤16 chars).",
760
+ );
761
+ return;
762
+ }
763
+ updatePetStats((s) => applyName(s, cleaned));
764
+ addItem("system", `Pet renamed: "${cleaned}". Memo distributed to all departments.`);
765
+ return;
766
+ }
767
+ if (slashCommand === "/profile") {
768
+ const s = petStatsRef.current;
769
+ const tenure = formatTenure(petTenureMs(s));
770
+ const mood = getPetMood(s);
771
+ const stats = [
772
+ ["hunger", s.hunger],
773
+ ["happiness", s.happiness],
774
+ ["energy", s.energy],
775
+ ["deals", s.deals],
776
+ ] as const;
777
+ const dominant = stats.reduce(
778
+ (best, cur) => (cur[1] > best[1] ? cur : best),
779
+ );
780
+ const rank = getPetRank(s);
781
+ const lines = [
782
+ "Drexler personnel file:",
783
+ ` name : ${s.name ?? "(unnamed associate)"}`,
784
+ ` rank : ${rankLabel(rank)}`,
785
+ ` tenure : ${tenure}`,
786
+ ` mood : ${mood}`,
787
+ ` hunger : ${Math.round(s.hunger)}%`,
788
+ ` happiness : ${Math.round(s.happiness)}%`,
789
+ ` energy : ${Math.round(s.energy)}%`,
790
+ ` deals : ${Math.round(s.deals)}%`,
791
+ ` standout : ${dominant[0]} (${Math.round(dominant[1])}%)`,
792
+ ];
793
+ addItem("system", lines.join("\n"));
794
+ return;
795
+ }
796
+
442
797
  let captured = "";
443
798
  const mutableConfig: Config = { ...config, model };
444
799
  const action = dispatch(line, {
@@ -448,15 +803,8 @@ export function App({
448
803
  captured += (captured ? "\n" : "") + s;
449
804
  },
450
805
  });
451
- const lower = line.toLowerCase().trim();
452
- if (lower === "/synergy") {
453
- runSynergyEvent();
454
- return;
455
- }
456
806
  if (lower === "/clear" || lower.startsWith("/clear ")) {
457
807
  setItems([]);
458
- setLastLatencyMs(null);
459
- setFallbackModel(null);
460
808
  }
461
809
  if (mutableConfig.model !== model) {
462
810
  setModel(mutableConfig.model);
@@ -493,7 +841,6 @@ export function App({
493
841
  await runLLM(action.instruction);
494
842
  }
495
843
  setMsgCount(conversation.length);
496
- setTokenCount(conversation.approximateTokens());
497
844
  },
498
845
  [
499
846
  addItem,
@@ -501,10 +848,14 @@ export function App({
501
848
  config,
502
849
  activeTheme,
503
850
  model,
851
+ petStats,
852
+ PET_MESSAGES,
504
853
  removeLastAssistantItem,
505
854
  runLLM,
506
855
  runSynergyEvent,
507
856
  triggerExit,
857
+ triggerPetActivity,
858
+ updatePetStats,
508
859
  ],
509
860
  );
510
861
 
@@ -523,13 +874,46 @@ export function App({
523
874
  addItem("user", line);
524
875
  conversation.push("user", line);
525
876
  setMsgCount(conversation.length);
526
- setTokenCount(conversation.approximateTokens());
527
877
  await runLLM();
528
878
  },
529
879
  [addItem, conversation, handleSlashWithMutation, runLLM],
530
880
  );
531
881
 
882
+ const reportSubmitError = useCallback(
883
+ (err: unknown) => {
884
+ const msg = err instanceof Error ? err.message : String(err);
885
+ addItem("system", `${STREAM_ERROR} [${msg}]`);
886
+ setDeskStatus("error");
887
+ setDeskNotice("submit failed");
888
+ },
889
+ [addItem],
890
+ );
891
+
532
892
  useInput((char, key) => {
893
+ // Scroll keys are always live — they only mutate scrollOffset and never
894
+ // commit input, so we let the user review history during streaming.
895
+ if (key.pageUp) {
896
+ setScrollOffset((offset) =>
897
+ nextTranscriptScrollOffset({
898
+ current: offset,
899
+ totalRows: estimatedTranscriptRows,
900
+ visibleRows: visibleTranscriptRows,
901
+ direction: "older",
902
+ }),
903
+ );
904
+ return;
905
+ }
906
+ if (key.pageDown) {
907
+ setScrollOffset((offset) =>
908
+ nextTranscriptScrollOffset({
909
+ current: offset,
910
+ totalRows: estimatedTranscriptRows,
911
+ visibleRows: visibleTranscriptRows,
912
+ direction: "newer",
913
+ }),
914
+ );
915
+ return;
916
+ }
533
917
  const busy =
534
918
  requestInFlightRef.current ||
535
919
  synergyActiveRef.current ||
@@ -560,32 +944,25 @@ export function App({
560
944
  }
561
945
  return;
562
946
  }
563
- if (key.pageUp) {
564
- setScrollOffset((offset) =>
565
- nextTranscriptScrollOffset({
566
- current: offset,
567
- itemCount: items.length,
568
- direction: "older",
569
- }),
570
- );
571
- return;
572
- }
573
- if (key.pageDown) {
574
- setScrollOffset((offset) =>
575
- nextTranscriptScrollOffset({
576
- current: offset,
577
- itemCount: items.length,
578
- direction: "newer",
579
- }),
580
- );
581
- return;
582
- }
583
947
  if (paletteOpen && key.return) {
584
948
  const sel = paletteItems[paletteIdx];
585
949
  if (sel) {
950
+ // Bare /theme, /model, etc. — open the chooser, do not execute.
951
+ if (isArgumentParentCommand(sel.name)) {
952
+ const filled = sel.name + " ";
953
+ updateDraft({
954
+ value: filled,
955
+ cursor: graphemeLength(filled),
956
+ });
957
+ setPaletteIdx(0);
958
+ return;
959
+ }
586
960
  updateDraft({ value: "", cursor: 0 });
587
961
  setHistoryIdx(null);
588
- void onSubmit(sel.name);
962
+ historyDraftRef.current = null;
963
+ onSubmit(sel.name).catch((err) => {
964
+ reportSubmitError(err);
965
+ });
589
966
  }
590
967
  return;
591
968
  }
@@ -593,6 +970,7 @@ export function App({
593
970
  const submitted = draftRef.current.value;
594
971
  updateDraft({ value: "", cursor: 0 });
595
972
  setHistoryIdx(null);
973
+ historyDraftRef.current = null;
596
974
  const trimmedSubmit = submitted.trim();
597
975
  if (trimmedSubmit.length > 0) {
598
976
  setHistory((prev) => {
@@ -600,7 +978,9 @@ export function App({
600
978
  return next.length > 50 ? next.slice(-50) : next;
601
979
  });
602
980
  }
603
- void onSubmit(submitted);
981
+ onSubmit(submitted).catch((err) => {
982
+ reportSubmitError(err);
983
+ });
604
984
  return;
605
985
  }
606
986
  if (key.ctrl && char === "c") {
@@ -644,10 +1024,18 @@ export function App({
644
1024
  return;
645
1025
  }
646
1026
  if (history.length === 0) return;
647
- const idx = historyIdx === null ? history.length - 1 : Math.max(0, historyIdx - 1);
648
- const entry = history[idx] ?? "";
649
- setHistoryIdx(idx);
650
- updateDraft({ value: entry, cursor: graphemeLength(entry) });
1027
+ const next = historyNavStep(
1028
+ {
1029
+ historyIdx,
1030
+ draft: draftRef.current,
1031
+ historyDraft: historyDraftRef.current,
1032
+ },
1033
+ history,
1034
+ "up",
1035
+ );
1036
+ historyDraftRef.current = next.historyDraft;
1037
+ setHistoryIdx(next.historyIdx);
1038
+ updateDraft(next.draft);
651
1039
  return;
652
1040
  }
653
1041
  if (key.downArrow) {
@@ -656,15 +1044,18 @@ export function App({
656
1044
  return;
657
1045
  }
658
1046
  if (historyIdx === null) return;
659
- const next = historyIdx + 1;
660
- if (next >= history.length) {
661
- setHistoryIdx(null);
662
- updateDraft({ value: "", cursor: 0 });
663
- } else {
664
- const entry = history[next] ?? "";
665
- setHistoryIdx(next);
666
- updateDraft({ value: entry, cursor: graphemeLength(entry) });
667
- }
1047
+ const next = historyNavStep(
1048
+ {
1049
+ historyIdx,
1050
+ draft: draftRef.current,
1051
+ historyDraft: historyDraftRef.current,
1052
+ },
1053
+ history,
1054
+ "down",
1055
+ );
1056
+ historyDraftRef.current = next.historyDraft;
1057
+ setHistoryIdx(next.historyIdx);
1058
+ updateDraft(next.draft);
668
1059
  return;
669
1060
  }
670
1061
  if (key.ctrl && char === "a") {
@@ -721,24 +1112,25 @@ export function App({
721
1112
  const headerStatus = isBusy ? "streaming" : deskStatus;
722
1113
  const renderDealDeskHeader = (width: number) => (
723
1114
  <DealDeskHeader
724
- model={model}
725
1115
  mood={mood}
726
1116
  messageCount={msgCount}
727
- themeName={themeName}
728
- approximateTokens={tokenCount}
729
- latencyMs={lastLatencyMs}
730
- fallbackModel={fallbackModel}
731
1117
  status={headerStatus}
732
1118
  compact={isCompact}
733
1119
  notice={!integratedIntro ? deskNotice ?? undefined : undefined}
734
- maxWidth={integratedIntro ? Math.min(72, Math.max(1, width)) : width}
1120
+ maxWidth={Math.max(1, width)}
735
1121
  marginBottom={integratedIntro ? 0 : 1}
736
1122
  />
737
1123
  );
738
1124
  const dealDeskHeader = renderDealDeskHeader(chromeWidth);
739
- const visibleTranscriptRows = synergyEvent
740
- ? Math.max(1, maxTranscriptRows - synergyEventRows(chromeWidth, isCompact))
741
- : maxTranscriptRows;
1125
+ const introBarColor = introPhaseColor(intro.colorPhase, t);
1126
+
1127
+ if (isDead) {
1128
+ return (
1129
+ <ThemeProvider value={activeTheme}>
1130
+ <DeathScreen reason={deathReason} variant={deathVariant} />
1131
+ </ThemeProvider>
1132
+ );
1133
+ }
742
1134
 
743
1135
  return (
744
1136
  <ThemeProvider value={activeTheme}>
@@ -748,79 +1140,98 @@ export function App({
748
1140
  <MascotDashboard
749
1141
  greeting={greeting}
750
1142
  width={chromeWidth}
1143
+ mood={mood}
1144
+ bootProgress={intro.progress}
1145
+ state={intro.state}
1146
+ bar={intro.bar}
1147
+ barColor={introBarColor}
1148
+ mascotStatus={intro.status}
751
1149
  dealDesk={renderDealDeskHeader}
752
1150
  />
753
1151
  </Box>
754
1152
  ) : (
755
1153
  dealDeskHeader
756
1154
  )}
757
- <TranscriptViewport
758
- items={items}
759
- maxRows={visibleTranscriptRows}
760
- cols={chromeWidth}
761
- compact={isCompact}
762
- scrollOffset={scrollOffset}
763
- />
764
-
765
- <Box flexDirection="column">
766
- {streaming !== null && (
767
- <Box marginBottom={1}>
768
- <StreamingMessage content={streaming} width={chromeWidth} />
1155
+ <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
+ />
769
1164
  </Box>
770
1165
  )}
771
- {thinking !== null && streaming === null && (
772
- <Box marginBottom={1}>
773
- <Spinner label={thinking} width={chromeWidth} />
774
- </Box>
775
- )}
776
- {synergyEvent !== null && (
777
- <SynergyEvent
778
- event={synergyEvent.event}
779
- frame={synergyEvent.frame}
780
- width={chromeWidth}
1166
+ <Box flexDirection="column" flexGrow={1}>
1167
+ <TranscriptViewport
1168
+ items={items}
1169
+ maxRows={visibleTranscriptRows}
1170
+ cols={contentWidth}
781
1171
  compact={isCompact}
1172
+ scrollOffset={scrollOffset}
782
1173
  />
783
- )}
784
- {exitMsg !== null ? (
785
- <Box paddingX={1} marginBottom={1}>
786
- <Text color={t.primaryLight} bold>
787
- {exitMsg}
788
- </Text>
789
- </Box>
790
- ) : (
791
- <>
792
- {paletteOpen && (
793
- <CommandPalette
794
- items={paletteItems}
795
- selectedIdx={paletteIdx}
796
- width={chromeWidth}
797
- />
1174
+ <Box flexDirection="column">
1175
+ {streaming !== null && (
1176
+ <Box marginBottom={1}>
1177
+ <StreamingMessage content={streaming} width={contentWidth} />
1178
+ </Box>
798
1179
  )}
799
- <Box flexDirection="column">
800
- <InputBox
801
- value={input}
802
- cursor={cursor}
803
- disabled={isBusy}
804
- disabledLabel={
805
- synergyEvent !== null
806
- ? "(Synergy event running... boardroom locked)"
807
- : undefined
808
- }
809
- width={inputWidth}
810
- />
811
- </Box>
812
- <Box>
813
- <StatusBar
814
- messageCount={msgCount}
815
- witticism={witticism}
816
- maxWidth={statusBarWidth}
817
- status={isBusy ? "streaming" : deskStatus}
1180
+ {thinking !== null && streaming === null && (
1181
+ <Box paddingX={1} marginBottom={1}>
1182
+ <Spinner label={thinking} width={contentWidth} />
1183
+ </Box>
1184
+ )}
1185
+ {synergyEvent !== null && (
1186
+ <SynergyEvent
1187
+ event={synergyEvent.event}
1188
+ frame={synergyEvent.frame}
1189
+ width={contentWidth}
818
1190
  compact={isCompact}
819
- scrollHint={scrollHint}
820
1191
  />
821
- </Box>
822
- </>
823
- )}
1192
+ )}
1193
+ {exitMsg !== null ? (
1194
+ <Box paddingX={1} marginBottom={1}>
1195
+ <Text color={t.primaryLight} bold>
1196
+ {exitMsg}
1197
+ </Text>
1198
+ </Box>
1199
+ ) : (
1200
+ <>
1201
+ {paletteOpen && (
1202
+ <CommandPalette
1203
+ items={paletteItems}
1204
+ selectedIdx={paletteIdx}
1205
+ width={contentWidth}
1206
+ />
1207
+ )}
1208
+ <Box flexDirection="column">
1209
+ <InputBox
1210
+ value={input}
1211
+ cursor={cursor}
1212
+ disabled={isBusy}
1213
+ disabledLabel={
1214
+ synergyEvent !== null
1215
+ ? "(Synergy event running... boardroom locked)"
1216
+ : undefined
1217
+ }
1218
+ width={contentInputWidth}
1219
+ />
1220
+ </Box>
1221
+ <Box paddingLeft={2}>
1222
+ <StatusBar
1223
+ messageCount={msgCount}
1224
+ witticism={witticism}
1225
+ maxWidth={contentStatusWidth}
1226
+ status={isBusy ? "streaming" : deskStatus}
1227
+ compact={isCompact}
1228
+ scrollHint={scrollHint}
1229
+ />
1230
+ </Box>
1231
+ </>
1232
+ )}
1233
+ </Box>
1234
+ </Box>
824
1235
  </Box>
825
1236
  </Box>
826
1237
  </ThemeProvider>