drexler 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/App.tsx CHANGED
@@ -1,8 +1,45 @@
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
+ CompactPetPanel,
32
+ COMPACT_PET_PANEL_MIN_WIDTH,
33
+ COMPACT_PET_PANEL_ROWS,
34
+ PetPanel,
35
+ PET_PANEL_WIDTH,
36
+ TINY_PET_PANEL_ROWS,
37
+ type Environment,
38
+ } from "./PetPanel.tsx";
3
39
  import {
4
40
  dispatch,
5
41
  filterPaletteByPrefix,
42
+ isArgumentParentCommand,
6
43
  isSlash,
7
44
  type CommandAction,
8
45
  } from "../commands.ts";
@@ -23,7 +60,6 @@ import {
23
60
  WITTICISMS,
24
61
  } from "../sayings.ts";
25
62
  import { type Config } from "../types.ts";
26
- import { THEME_NAMES } from "../types.ts";
27
63
  import { CommandPalette } from "./CommandPalette.tsx";
28
64
  import { DealDeskHeader } from "./DealDeskHeader.tsx";
29
65
  import {
@@ -50,10 +86,17 @@ import {
50
86
  type SynergyEventDefinition,
51
87
  } from "./SynergyEvent.tsx";
52
88
  import { ThemeProvider } from "./ThemeContext.tsx";
53
- import { TranscriptViewport } from "./TranscriptViewport.tsx";
54
- import { getActiveTheme, THEMES } from "./themes.ts";
89
+ import {
90
+ estimateTranscriptRows,
91
+ TranscriptViewport,
92
+ } from "./TranscriptViewport.tsx";
93
+ import { getActiveTheme } from "./themes.ts";
55
94
 
56
95
  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;
57
100
 
58
101
  export function transcriptRowsForTerminalRows(rows: number): number {
59
102
  return Math.max(1, Math.min(24, rows - TRANSCRIPT_CHROME_ROWS));
@@ -62,15 +105,22 @@ export function transcriptRowsForTerminalRows(rows: number): number {
62
105
  export function nextTranscriptScrollOffset({
63
106
  current,
64
107
  itemCount,
108
+ totalRows,
109
+ visibleRows,
65
110
  direction,
66
111
  step = 3,
67
112
  }: {
68
113
  current: number;
69
- itemCount: number;
114
+ itemCount?: number;
115
+ totalRows?: number;
116
+ visibleRows?: number;
70
117
  direction: "older" | "newer";
71
118
  step?: number;
72
119
  }): number {
73
- const maxOffset = Math.max(0, itemCount - 1);
120
+ const maxOffset =
121
+ totalRows !== undefined
122
+ ? Math.max(0, totalRows - Math.max(1, visibleRows ?? 1))
123
+ : Math.max(0, (itemCount ?? 0) - 1);
74
124
  if (direction === "older") {
75
125
  return Math.min(maxOffset, current + step);
76
126
  }
@@ -83,6 +133,46 @@ export function shouldRemoveVisibleAssistantForAction(
83
133
  return action.type === "regenerate" && action.removedAssistant;
84
134
  }
85
135
 
136
+ export interface HistoryNavState {
137
+ historyIdx: number | null;
138
+ draft: { value: string; cursor: number };
139
+ historyDraft: { value: string; cursor: number } | null;
140
+ }
141
+
142
+ export function historyNavStep(
143
+ state: HistoryNavState,
144
+ history: readonly string[],
145
+ direction: "up" | "down",
146
+ ): HistoryNavState {
147
+ if (direction === "up") {
148
+ if (history.length === 0) return state;
149
+ const snapshot =
150
+ state.historyIdx === null ? { ...state.draft } : state.historyDraft;
151
+ const idx =
152
+ state.historyIdx === null
153
+ ? history.length - 1
154
+ : Math.max(0, state.historyIdx - 1);
155
+ const entry = history[idx] ?? "";
156
+ return {
157
+ historyIdx: idx,
158
+ draft: { value: entry, cursor: graphemeLength(entry) },
159
+ historyDraft: snapshot,
160
+ };
161
+ }
162
+ if (state.historyIdx === null) return state;
163
+ const next = state.historyIdx + 1;
164
+ if (next >= history.length) {
165
+ const restored = state.historyDraft ?? { value: "", cursor: 0 };
166
+ return { historyIdx: null, draft: restored, historyDraft: null };
167
+ }
168
+ const entry = history[next] ?? "";
169
+ return {
170
+ historyIdx: next,
171
+ draft: { value: entry, cursor: graphemeLength(entry) },
172
+ historyDraft: state.historyDraft,
173
+ };
174
+ }
175
+
86
176
  function pick<T>(arr: readonly T[]): T {
87
177
  if (arr.length === 0) {
88
178
  throw new Error("pick called on empty array");
@@ -136,21 +226,38 @@ export function App({
136
226
  };
137
227
  }, [stdout]);
138
228
  const mode = useMemo(() => pickLayout(cols), [cols]);
139
- const inputWidth = useMemo(() => Math.max(1, cols), [cols]);
140
229
  const chromeWidth = useMemo(() => Math.max(1, cols), [cols]);
141
- const statusBarWidth = inputWidth;
142
230
  const isCompact = mode === "very-narrow";
231
+ const [introDone, setIntroDone] = useState(false);
143
232
  const integratedIntro =
144
- showIntroChrome && typeof greeting === "string" && rows >= 32;
233
+ showIntroChrome &&
234
+ typeof greeting === "string" &&
235
+ rows >= 32 &&
236
+ !introDone;
237
+ const showPetSidePanel = cols >= PET_PANEL_MIN_COLUMNS && !integratedIntro;
238
+ const showCompactPetPanel = !showPetSidePanel && !integratedIntro;
239
+ const compactPetRowBudget = showCompactPetPanel
240
+ ? cols >= COMPACT_PET_PANEL_MIN_WIDTH
241
+ ? COMPACT_PET_PANEL_ROWS
242
+ : TINY_PET_PANEL_ROWS
243
+ : 0;
244
+ const petPanelReservedWidth = showPetSidePanel
245
+ ? PET_PANEL_WIDTH + PET_PANEL_GAP_COLUMNS
246
+ : 0;
247
+ const contentWidth = showPetSidePanel
248
+ ? Math.max(1, cols - petPanelReservedWidth)
249
+ : chromeWidth;
250
+ const contentInputWidth = Math.max(1, contentWidth);
251
+ const contentStatusWidth = Math.max(1, contentInputWidth - 2);
145
252
  const introRowBudget =
146
253
  integratedIntro ? (chromeWidth >= 112 ? 14 : chromeWidth >= 72 ? 26 : 6) : 0;
147
254
  const maxTranscriptRows = useMemo(
148
255
  () =>
149
256
  Math.max(
150
257
  1,
151
- transcriptRowsForTerminalRows(rows) - introRowBudget,
258
+ transcriptRowsForTerminalRows(rows) - introRowBudget - compactPetRowBudget,
152
259
  ),
153
- [introRowBudget, rows],
260
+ [compactPetRowBudget, introRowBudget, rows],
154
261
  );
155
262
 
156
263
  const [items, setItems] = useState<ChatItem[]>([]);
@@ -197,18 +304,155 @@ export function App({
197
304
  const [witticism, setWitticism] = useState<string>(pick(WITTICISMS));
198
305
  const [model, setModel] = useState<string>(config.model);
199
306
  const [msgCount, setMsgCount] = useState<number>(0);
200
- const [tokenCount, setTokenCount] = useState<number>(
201
- conversation.approximateTokens(),
202
- );
203
- const [lastLatencyMs, setLastLatencyMs] = useState<number | null>(null);
204
- const [fallbackModel, setFallbackModel] = useState<string | null>(null);
205
307
  const [deskStatus, setDeskStatus] = useState<"idle" | "error">("idle");
206
308
  const [deskNotice, setDeskNotice] = useState<string | null>(null);
207
309
  const [history, setHistory] = useState<string[]>([]);
208
310
  const [historyIdx, setHistoryIdx] = useState<number | null>(null);
209
311
  const [paletteIdx, setPaletteIdx] = useState(0);
210
312
  const [scrollOffset, setScrollOffset] = useState(0);
211
- const intro = useIntroAnimation(chromeWidth, integratedIntro);
313
+ const handleIntroComplete = useCallback(() => {
314
+ setIntroDone(true);
315
+ }, []);
316
+ const intro = useIntroAnimation(
317
+ chromeWidth,
318
+ integratedIntro,
319
+ handleIntroComplete,
320
+ );
321
+
322
+ const [petStats, setPetStats] = useState<PetStats>(() => loadPetState());
323
+ const [petActivity, setPetActivity] = useState<PetActivity>("idle");
324
+ const [isDead, setIsDead] = useState(false);
325
+ const [deathReason, setDeathReason] = useState("energy");
326
+ const [deathVariant, setDeathVariant] = useState(0);
327
+ const petStatsRef = useRef<PetStats>(petStats);
328
+ const petActivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
329
+ const petDecayTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
330
+
331
+ const petEnv = useMemo((): Environment => {
332
+ const h = new Date().getHours();
333
+ if (h >= 9 && h < 18) return "office";
334
+ if (h >= 6 && h < 23) return "home";
335
+ return "outdoors";
336
+ }, []);
337
+
338
+ const PET_MESSAGES = useMemo(() => ({
339
+ feed: [
340
+ "Drexler receives deal memo. Hunger: satisfied. Pipeline: expanding.",
341
+ "Drexler consumes quarterly report. Fortifying.",
342
+ "Deal deck delivered. Drexler is replenished.",
343
+ "Nutrition acquired via term sheet. Excellent.",
344
+ "Drexler ingests synergy bundle. Caloric intake: maximized.",
345
+ "Pipeline refueled. Drexler gives brief nod of approval.",
346
+ ],
347
+ play: [
348
+ "Drexler engages in corporate synergy games. Morale: elevated.",
349
+ "Drexler attempts leisure. Unfamiliar but effective.",
350
+ "Golf simulation initiated. Handicap: nonexistent.",
351
+ "Corporate retreat protocols engaged. Team building: successful.",
352
+ "Drexler plays. Competitors watch nervously.",
353
+ "Recreational time allocated. ROI: unclear but positive.",
354
+ ],
355
+ work: [
356
+ "Drexler retreats to deal desk. Pipeline throughput: increasing.",
357
+ "Grind mode initiated. Coffee consumed preemptively.",
358
+ "Drexler is doing the work. Others take note.",
359
+ "Deal origination in progress. Board is watching.",
360
+ "Drexler enters flow state. Productivity: exceptional.",
361
+ "All-nighter commenced. Regrets: minimal.",
362
+ ],
363
+ praise: [
364
+ "Drexler acknowledges commendation. Briefly.",
365
+ "Praise received. Filed under: expected.",
366
+ "Drexler nods. One singular nod.",
367
+ "Affirmation noted. Drexler remains unmoved. Mostly.",
368
+ "Kind words processed. Ego: appropriately inflated.",
369
+ "Drexler accepts compliment with characteristic restraint.",
370
+ ],
371
+ rest: [
372
+ "Drexler retires briefly. Strategic recharge in progress.",
373
+ "Under-desk nap initiated. Do not disturb.",
374
+ "Drexler powers down. Temporarily.",
375
+ "Rest mode engaged. Energy recovery: imminent.",
376
+ "Strategic downtime commenced. Drexler will return stronger.",
377
+ "Drexler sleeps. Dreams of closed deals.",
378
+ ],
379
+ }), []);
380
+
381
+ const triggerPetActivity = useCallback(
382
+ (activity: PetActivity, durationMs: number) => {
383
+ if (petActivityTimerRef.current !== null) {
384
+ clearTimeout(petActivityTimerRef.current);
385
+ }
386
+ setPetActivity(activity);
387
+ petActivityTimerRef.current = setTimeout(() => {
388
+ setPetActivity("idle");
389
+ petActivityTimerRef.current = null;
390
+ }, durationMs);
391
+ },
392
+ [],
393
+ );
394
+ const updatePetStats = useCallback((updater: (stats: PetStats) => PetStats) => {
395
+ setPetStats((stats) => {
396
+ const next = updater(stats);
397
+ petStatsRef.current = next;
398
+ savePetState(next);
399
+ return next;
400
+ });
401
+ }, []);
402
+ const applyPetAction = useCallback(
403
+ (action: PetActionKey, mutator: (stats: PetStats) => PetStats) => {
404
+ const before = getPetRank(petStatsRef.current);
405
+ updatePetStats((s) => {
406
+ const next = stampAction(mutator(s), action);
407
+ return accrueLifetimeDeals(next, action);
408
+ });
409
+ const after = getPetRank(petStatsRef.current);
410
+ if (after !== before) {
411
+ addItem(
412
+ "system",
413
+ `PROMOTION MEMO: Drexler ranked up to ${rankLabel(after)}. Reward: more meetings.`,
414
+ );
415
+ }
416
+ },
417
+ [updatePetStats, addItem],
418
+ );
419
+
420
+ useEffect(() => {
421
+ petStatsRef.current = petStats;
422
+ }, [petStats]);
423
+
424
+ // Real-time stat decay matches the offline per-hour decay rate.
425
+ useEffect(() => {
426
+ petDecayTimerRef.current = setInterval(() => {
427
+ updatePetStats(applyMinuteDecay);
428
+ }, 60_000);
429
+ return () => {
430
+ if (petDecayTimerRef.current !== null) {
431
+ clearInterval(petDecayTimerRef.current);
432
+ petDecayTimerRef.current = null;
433
+ }
434
+ if (petActivityTimerRef.current !== null) {
435
+ clearTimeout(petActivityTimerRef.current);
436
+ petActivityTimerRef.current = null;
437
+ }
438
+ savePetState(petStatsRef.current);
439
+ };
440
+ }, [updatePetStats]);
441
+
442
+ // Death detection
443
+ useEffect(() => {
444
+ if (isDead || !isPetDead(petStats)) return;
445
+ const reason =
446
+ petStats.hunger <= 0 ? "hunger" :
447
+ petStats.happiness <= 0 ? "happiness" : "energy";
448
+ setDeathReason(reason);
449
+ setDeathVariant(Math.floor(Math.random() * 5));
450
+ setIsDead(true);
451
+ const deadStats = { ...petStats, dead: true };
452
+ petStatsRef.current = deadStats;
453
+ savePetState(deadStats);
454
+ exitTimerRef.current = setTimeout(() => exit(), 5000);
455
+ }, [petStats, isDead, exit]);
212
456
 
213
457
  const paletteItems = useMemo(() => filterPaletteByPrefix(input), [input]);
214
458
  const paletteOpen = paletteItems.length > 0;
@@ -220,23 +464,17 @@ export function App({
220
464
  setScrollOffset(0);
221
465
  }, [items.length]);
222
466
 
223
- useEffect(() => {
224
- setTokenCount(conversation.approximateTokens());
225
- }, [conversation, msgCount]);
226
-
227
- const themeName = useMemo(() => {
228
- const active = getActiveTheme();
229
- return (
230
- THEME_NAMES.find((name) => THEMES[name] === active) ??
231
- config.theme ??
232
- "apollo"
233
- );
234
- }, [activeTheme, config.theme]);
235
-
467
+ const visibleTranscriptRows = synergyEvent
468
+ ? Math.max(1, maxTranscriptRows - synergyEventRows(contentWidth, isCompact))
469
+ : maxTranscriptRows;
470
+ const estimatedTranscriptRows = useMemo(
471
+ () => estimateTranscriptRows(items, isCompact, contentWidth),
472
+ [items, isCompact, contentWidth],
473
+ );
236
474
  const scrollHint = useMemo(() => {
237
- if (items.length <= maxTranscriptRows) return undefined;
475
+ if (estimatedTranscriptRows <= visibleTranscriptRows) return undefined;
238
476
  return scrollOffset > 0 ? "PageDown newer" : "PageUp scrollback";
239
- }, [items.length, maxTranscriptRows, scrollOffset]);
477
+ }, [estimatedTranscriptRows, visibleTranscriptRows, scrollOffset]);
240
478
 
241
479
  // throttle streaming updates so React doesn't re-render every token
242
480
  const streamBufRef = useRef("");
@@ -249,6 +487,7 @@ export function App({
249
487
  const exitingRef = useRef(false);
250
488
  const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
251
489
  const synergyTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
490
+ const historyDraftRef = useRef<{ value: string; cursor: number } | null>(null);
252
491
  const flushStream = useCallback(() => {
253
492
  if (!mountedRef.current) return;
254
493
  setStreaming(streamBufRef.current);
@@ -260,6 +499,7 @@ export function App({
260
499
  if (exitingRef.current) return;
261
500
  exitingRef.current = true;
262
501
  abortRef.current?.abort();
502
+ savePetState(petStatsRef.current);
263
503
  if (streamTimerRef.current !== null) {
264
504
  clearTimeout(streamTimerRef.current);
265
505
  streamTimerRef.current = null;
@@ -333,12 +573,10 @@ export function App({
333
573
  if (requestInFlightRef.current) return;
334
574
  requestInFlightRef.current = true;
335
575
  setRequestInFlight(true);
336
- const startedAt = Date.now();
337
576
  try {
338
577
  setThinking(pick(THINKING_LINES));
339
578
  setDeskStatus("idle");
340
579
  setDeskNotice(null);
341
- setFallbackModel(null);
342
580
  streamBufRef.current = "";
343
581
  setStreaming(null);
344
582
  let firstToken = true;
@@ -389,7 +627,6 @@ export function App({
389
627
  }
390
628
  setThinking(null);
391
629
  setStreaming(null);
392
- setLastLatencyMs(Date.now() - startedAt);
393
630
  if (cancelledRef.current) {
394
631
  cancelledRef.current = false;
395
632
  if (result?.content) {
@@ -405,7 +642,6 @@ export function App({
405
642
  if (result.fellBack) {
406
643
  addItem("system", `(fell back to ${result.modelUsed})`);
407
644
  notices.push(`fallback ${result.modelUsed}`);
408
- setFallbackModel(result.modelUsed);
409
645
  }
410
646
  if (detectPersonaDrift(result.content)) {
411
647
  addItem("system", `(persona drift detected — model used 'I')`);
@@ -425,7 +661,6 @@ export function App({
425
661
  setDeskNotice(result?.error ?? "stream error");
426
662
  }
427
663
  setMsgCount(conversation.length);
428
- setTokenCount(conversation.approximateTokens());
429
664
  setWitticism(pick(WITTICISMS));
430
665
  } finally {
431
666
  requestInFlightRef.current = false;
@@ -444,6 +679,134 @@ export function App({
444
679
 
445
680
  const handleSlashWithMutation = useCallback(
446
681
  async (line: string): Promise<void> => {
682
+ const lower = line.toLowerCase().trim();
683
+ const [slashCommand = lower] = lower.split(/\s+/, 1);
684
+
685
+ // Pet commands — handled before dispatch so they don't hit the unknown-command path
686
+ if (lower === "/synergy") {
687
+ runSynergyEvent();
688
+ return;
689
+ }
690
+ const isPetCommand =
691
+ slashCommand === "/feed" ||
692
+ slashCommand === "/play" ||
693
+ slashCommand === "/work" ||
694
+ slashCommand === "/praise" ||
695
+ slashCommand === "/rest" ||
696
+ slashCommand === "/vibe";
697
+ if (isPetCommand && isDead) {
698
+ addItem("system", "Drexler is in HR. Restructuring paperwork pending — try again after revival.");
699
+ return;
700
+ }
701
+ const cooldownAction: PetActionKey | null =
702
+ slashCommand === "/feed" ? "feed"
703
+ : slashCommand === "/play" ? "play"
704
+ : slashCommand === "/work" ? "work"
705
+ : slashCommand === "/praise" ? "praise"
706
+ : slashCommand === "/rest" ? "rest"
707
+ : slashCommand === "/vibe" ? "vibe"
708
+ : null;
709
+ if (cooldownAction !== null) {
710
+ const cd = actionCooldown(petStatsRef.current, cooldownAction);
711
+ if (!cd.ok) {
712
+ addItem(
713
+ "system",
714
+ `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.`,
715
+ );
716
+ return;
717
+ }
718
+ }
719
+ if (slashCommand === "/feed") {
720
+ applyPetAction("feed", applyFeed);
721
+ triggerPetActivity("eating", 3500);
722
+ addItem("system", pick(PET_MESSAGES.feed));
723
+ return;
724
+ }
725
+ if (slashCommand === "/play") {
726
+ applyPetAction("play", applyPlay);
727
+ triggerPetActivity("playing", 4000);
728
+ addItem("system", pick(PET_MESSAGES.play));
729
+ return;
730
+ }
731
+ if (slashCommand === "/work") {
732
+ applyPetAction("work", applyWork);
733
+ triggerPetActivity("working", 5000);
734
+ addItem("system", pick(PET_MESSAGES.work));
735
+ return;
736
+ }
737
+ if (slashCommand === "/praise") {
738
+ applyPetAction("praise", applyPraise);
739
+ triggerPetActivity("praised", 3000);
740
+ addItem("system", pick(PET_MESSAGES.praise));
741
+ return;
742
+ }
743
+ if (slashCommand === "/rest") {
744
+ applyPetAction("rest", applyRest);
745
+ triggerPetActivity("sleeping", 5000);
746
+ addItem("system", pick(PET_MESSAGES.rest));
747
+ return;
748
+ }
749
+ if (slashCommand === "/vibe") {
750
+ const result = applyVibe(petStatsRef.current);
751
+ applyPetAction("vibe", () => result.stats);
752
+ triggerPetActivity("vibing", 3500);
753
+ addItem("system", result.message);
754
+ return;
755
+ }
756
+ if (slashCommand === "/name") {
757
+ const arg = line.slice("/name".length).trim();
758
+ if (arg.length === 0) {
759
+ const current = petStatsRef.current.name;
760
+ addItem(
761
+ "system",
762
+ current
763
+ ? `Drexler's pet name on file: "${current}". /name <new> to reassign.`
764
+ : "No pet name on file. /name <name> to issue corporate identity.",
765
+ );
766
+ return;
767
+ }
768
+ const cleaned = sanitizePetName(arg);
769
+ if (cleaned.length === 0) {
770
+ addItem(
771
+ "system",
772
+ "Drexler refuses unprintable identity. Pick letters, numbers, spaces, dots, or apostrophes (≤16 chars).",
773
+ );
774
+ return;
775
+ }
776
+ updatePetStats((s) => applyName(s, cleaned));
777
+ addItem("system", `Pet renamed: "${cleaned}". Memo distributed to all departments.`);
778
+ return;
779
+ }
780
+ if (slashCommand === "/profile") {
781
+ const s = petStatsRef.current;
782
+ const tenure = formatTenure(petTenureMs(s));
783
+ const mood = getPetMood(s);
784
+ const stats = [
785
+ ["hunger", s.hunger],
786
+ ["happiness", s.happiness],
787
+ ["energy", s.energy],
788
+ ["deals", s.deals],
789
+ ] as const;
790
+ const dominant = stats.reduce(
791
+ (best, cur) => (cur[1] > best[1] ? cur : best),
792
+ );
793
+ const rank = getPetRank(s);
794
+ const lines = [
795
+ "Drexler personnel file:",
796
+ ` name : ${s.name ?? "(unnamed associate)"}`,
797
+ ` rank : ${rankLabel(rank)}`,
798
+ ` tenure : ${tenure}`,
799
+ ` mood : ${mood}`,
800
+ ` hunger : ${Math.round(s.hunger)}%`,
801
+ ` happiness : ${Math.round(s.happiness)}%`,
802
+ ` energy : ${Math.round(s.energy)}%`,
803
+ ` deals : ${Math.round(s.deals)}%`,
804
+ ` standout : ${dominant[0]} (${Math.round(dominant[1])}%)`,
805
+ ];
806
+ addItem("system", lines.join("\n"));
807
+ return;
808
+ }
809
+
447
810
  let captured = "";
448
811
  const mutableConfig: Config = { ...config, model };
449
812
  const action = dispatch(line, {
@@ -453,15 +816,8 @@ export function App({
453
816
  captured += (captured ? "\n" : "") + s;
454
817
  },
455
818
  });
456
- const lower = line.toLowerCase().trim();
457
- if (lower === "/synergy") {
458
- runSynergyEvent();
459
- return;
460
- }
461
819
  if (lower === "/clear" || lower.startsWith("/clear ")) {
462
820
  setItems([]);
463
- setLastLatencyMs(null);
464
- setFallbackModel(null);
465
821
  }
466
822
  if (mutableConfig.model !== model) {
467
823
  setModel(mutableConfig.model);
@@ -498,7 +854,6 @@ export function App({
498
854
  await runLLM(action.instruction);
499
855
  }
500
856
  setMsgCount(conversation.length);
501
- setTokenCount(conversation.approximateTokens());
502
857
  },
503
858
  [
504
859
  addItem,
@@ -506,10 +861,14 @@ export function App({
506
861
  config,
507
862
  activeTheme,
508
863
  model,
864
+ petStats,
865
+ PET_MESSAGES,
509
866
  removeLastAssistantItem,
510
867
  runLLM,
511
868
  runSynergyEvent,
512
869
  triggerExit,
870
+ triggerPetActivity,
871
+ updatePetStats,
513
872
  ],
514
873
  );
515
874
 
@@ -528,13 +887,46 @@ export function App({
528
887
  addItem("user", line);
529
888
  conversation.push("user", line);
530
889
  setMsgCount(conversation.length);
531
- setTokenCount(conversation.approximateTokens());
532
890
  await runLLM();
533
891
  },
534
892
  [addItem, conversation, handleSlashWithMutation, runLLM],
535
893
  );
536
894
 
895
+ const reportSubmitError = useCallback(
896
+ (err: unknown) => {
897
+ const msg = err instanceof Error ? err.message : String(err);
898
+ addItem("system", `${STREAM_ERROR} [${msg}]`);
899
+ setDeskStatus("error");
900
+ setDeskNotice("submit failed");
901
+ },
902
+ [addItem],
903
+ );
904
+
537
905
  useInput((char, key) => {
906
+ // Scroll keys are always live — they only mutate scrollOffset and never
907
+ // commit input, so we let the user review history during streaming.
908
+ if (key.pageUp) {
909
+ setScrollOffset((offset) =>
910
+ nextTranscriptScrollOffset({
911
+ current: offset,
912
+ totalRows: estimatedTranscriptRows,
913
+ visibleRows: visibleTranscriptRows,
914
+ direction: "older",
915
+ }),
916
+ );
917
+ return;
918
+ }
919
+ if (key.pageDown) {
920
+ setScrollOffset((offset) =>
921
+ nextTranscriptScrollOffset({
922
+ current: offset,
923
+ totalRows: estimatedTranscriptRows,
924
+ visibleRows: visibleTranscriptRows,
925
+ direction: "newer",
926
+ }),
927
+ );
928
+ return;
929
+ }
538
930
  const busy =
539
931
  requestInFlightRef.current ||
540
932
  synergyActiveRef.current ||
@@ -565,32 +957,25 @@ export function App({
565
957
  }
566
958
  return;
567
959
  }
568
- if (key.pageUp) {
569
- setScrollOffset((offset) =>
570
- nextTranscriptScrollOffset({
571
- current: offset,
572
- itemCount: items.length,
573
- direction: "older",
574
- }),
575
- );
576
- return;
577
- }
578
- if (key.pageDown) {
579
- setScrollOffset((offset) =>
580
- nextTranscriptScrollOffset({
581
- current: offset,
582
- itemCount: items.length,
583
- direction: "newer",
584
- }),
585
- );
586
- return;
587
- }
588
960
  if (paletteOpen && key.return) {
589
961
  const sel = paletteItems[paletteIdx];
590
962
  if (sel) {
963
+ // Bare /theme, /model, etc. — open the chooser, do not execute.
964
+ if (isArgumentParentCommand(sel.name)) {
965
+ const filled = sel.name + " ";
966
+ updateDraft({
967
+ value: filled,
968
+ cursor: graphemeLength(filled),
969
+ });
970
+ setPaletteIdx(0);
971
+ return;
972
+ }
591
973
  updateDraft({ value: "", cursor: 0 });
592
974
  setHistoryIdx(null);
593
- void onSubmit(sel.name);
975
+ historyDraftRef.current = null;
976
+ onSubmit(sel.name).catch((err) => {
977
+ reportSubmitError(err);
978
+ });
594
979
  }
595
980
  return;
596
981
  }
@@ -598,6 +983,7 @@ export function App({
598
983
  const submitted = draftRef.current.value;
599
984
  updateDraft({ value: "", cursor: 0 });
600
985
  setHistoryIdx(null);
986
+ historyDraftRef.current = null;
601
987
  const trimmedSubmit = submitted.trim();
602
988
  if (trimmedSubmit.length > 0) {
603
989
  setHistory((prev) => {
@@ -605,7 +991,9 @@ export function App({
605
991
  return next.length > 50 ? next.slice(-50) : next;
606
992
  });
607
993
  }
608
- void onSubmit(submitted);
994
+ onSubmit(submitted).catch((err) => {
995
+ reportSubmitError(err);
996
+ });
609
997
  return;
610
998
  }
611
999
  if (key.ctrl && char === "c") {
@@ -649,10 +1037,18 @@ export function App({
649
1037
  return;
650
1038
  }
651
1039
  if (history.length === 0) return;
652
- const idx = historyIdx === null ? history.length - 1 : Math.max(0, historyIdx - 1);
653
- const entry = history[idx] ?? "";
654
- setHistoryIdx(idx);
655
- updateDraft({ value: entry, cursor: graphemeLength(entry) });
1040
+ const next = historyNavStep(
1041
+ {
1042
+ historyIdx,
1043
+ draft: draftRef.current,
1044
+ historyDraft: historyDraftRef.current,
1045
+ },
1046
+ history,
1047
+ "up",
1048
+ );
1049
+ historyDraftRef.current = next.historyDraft;
1050
+ setHistoryIdx(next.historyIdx);
1051
+ updateDraft(next.draft);
656
1052
  return;
657
1053
  }
658
1054
  if (key.downArrow) {
@@ -661,15 +1057,18 @@ export function App({
661
1057
  return;
662
1058
  }
663
1059
  if (historyIdx === null) return;
664
- const next = historyIdx + 1;
665
- if (next >= history.length) {
666
- setHistoryIdx(null);
667
- updateDraft({ value: "", cursor: 0 });
668
- } else {
669
- const entry = history[next] ?? "";
670
- setHistoryIdx(next);
671
- updateDraft({ value: entry, cursor: graphemeLength(entry) });
672
- }
1060
+ const next = historyNavStep(
1061
+ {
1062
+ historyIdx,
1063
+ draft: draftRef.current,
1064
+ historyDraft: historyDraftRef.current,
1065
+ },
1066
+ history,
1067
+ "down",
1068
+ );
1069
+ historyDraftRef.current = next.historyDraft;
1070
+ setHistoryIdx(next.historyIdx);
1071
+ updateDraft(next.draft);
673
1072
  return;
674
1073
  }
675
1074
  if (key.ctrl && char === "a") {
@@ -726,13 +1125,8 @@ export function App({
726
1125
  const headerStatus = isBusy ? "streaming" : deskStatus;
727
1126
  const renderDealDeskHeader = (width: number) => (
728
1127
  <DealDeskHeader
729
- model={model}
730
1128
  mood={mood}
731
1129
  messageCount={msgCount}
732
- themeName={themeName}
733
- approximateTokens={tokenCount}
734
- latencyMs={lastLatencyMs}
735
- fallbackModel={fallbackModel}
736
1130
  status={headerStatus}
737
1131
  compact={isCompact}
738
1132
  notice={!integratedIntro ? deskNotice ?? undefined : undefined}
@@ -741,11 +1135,16 @@ export function App({
741
1135
  />
742
1136
  );
743
1137
  const dealDeskHeader = renderDealDeskHeader(chromeWidth);
744
- const visibleTranscriptRows = synergyEvent
745
- ? Math.max(1, maxTranscriptRows - synergyEventRows(chromeWidth, isCompact))
746
- : maxTranscriptRows;
747
1138
  const introBarColor = introPhaseColor(intro.colorPhase, t);
748
1139
 
1140
+ if (isDead) {
1141
+ return (
1142
+ <ThemeProvider value={activeTheme}>
1143
+ <DeathScreen reason={deathReason} variant={deathVariant} />
1144
+ </ThemeProvider>
1145
+ );
1146
+ }
1147
+
749
1148
  return (
750
1149
  <ThemeProvider value={activeTheme}>
751
1150
  <Box flexDirection="column">
@@ -766,73 +1165,97 @@ export function App({
766
1165
  ) : (
767
1166
  dealDeskHeader
768
1167
  )}
769
- <TranscriptViewport
770
- items={items}
771
- maxRows={visibleTranscriptRows}
772
- cols={chromeWidth}
773
- compact={isCompact}
774
- scrollOffset={scrollOffset}
775
- />
776
-
777
- <Box flexDirection="column">
778
- {streaming !== null && (
779
- <Box marginBottom={1}>
780
- <StreamingMessage content={streaming} width={chromeWidth} />
781
- </Box>
782
- )}
783
- {thinking !== null && streaming === null && (
784
- <Box marginBottom={1}>
785
- <Spinner label={thinking} width={chromeWidth} />
1168
+ {showCompactPetPanel && (
1169
+ <Box marginBottom={1}>
1170
+ <CompactPetPanel
1171
+ stats={petStats}
1172
+ activity={petActivity}
1173
+ env={petEnv}
1174
+ isPaused={isBusy}
1175
+ width={chromeWidth}
1176
+ />
1177
+ </Box>
1178
+ )}
1179
+ <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
+ />
786
1188
  </Box>
787
1189
  )}
788
- {synergyEvent !== null && (
789
- <SynergyEvent
790
- event={synergyEvent.event}
791
- frame={synergyEvent.frame}
792
- width={chromeWidth}
1190
+ <Box flexDirection="column" flexGrow={1}>
1191
+ <TranscriptViewport
1192
+ items={items}
1193
+ maxRows={visibleTranscriptRows}
1194
+ cols={contentWidth}
793
1195
  compact={isCompact}
1196
+ scrollOffset={scrollOffset}
794
1197
  />
795
- )}
796
- {exitMsg !== null ? (
797
- <Box paddingX={1} marginBottom={1}>
798
- <Text color={t.primaryLight} bold>
799
- {exitMsg}
800
- </Text>
801
- </Box>
802
- ) : (
803
- <>
804
- {paletteOpen && (
805
- <CommandPalette
806
- items={paletteItems}
807
- selectedIdx={paletteIdx}
808
- width={chromeWidth}
809
- />
1198
+ <Box flexDirection="column">
1199
+ {streaming !== null && (
1200
+ <Box marginBottom={1}>
1201
+ <StreamingMessage content={streaming} width={contentWidth} />
1202
+ </Box>
810
1203
  )}
811
- <Box flexDirection="column">
812
- <InputBox
813
- value={input}
814
- cursor={cursor}
815
- disabled={isBusy}
816
- disabledLabel={
817
- synergyEvent !== null
818
- ? "(Synergy event running... boardroom locked)"
819
- : undefined
820
- }
821
- width={inputWidth}
822
- />
823
- </Box>
824
- <Box paddingLeft={2}>
825
- <StatusBar
826
- messageCount={msgCount}
827
- witticism={witticism}
828
- maxWidth={Math.max(1, statusBarWidth - 2)}
829
- status={isBusy ? "streaming" : deskStatus}
1204
+ {thinking !== null && streaming === null && (
1205
+ <Box paddingX={1} marginBottom={1}>
1206
+ <Spinner label={thinking} width={contentWidth} />
1207
+ </Box>
1208
+ )}
1209
+ {synergyEvent !== null && (
1210
+ <SynergyEvent
1211
+ event={synergyEvent.event}
1212
+ frame={synergyEvent.frame}
1213
+ width={contentWidth}
830
1214
  compact={isCompact}
831
- scrollHint={scrollHint}
832
1215
  />
833
- </Box>
834
- </>
835
- )}
1216
+ )}
1217
+ {exitMsg !== null ? (
1218
+ <Box paddingX={1} marginBottom={1}>
1219
+ <Text color={t.primaryLight} bold>
1220
+ {exitMsg}
1221
+ </Text>
1222
+ </Box>
1223
+ ) : (
1224
+ <>
1225
+ {paletteOpen && (
1226
+ <CommandPalette
1227
+ items={paletteItems}
1228
+ selectedIdx={paletteIdx}
1229
+ width={contentWidth}
1230
+ />
1231
+ )}
1232
+ <Box flexDirection="column">
1233
+ <InputBox
1234
+ value={input}
1235
+ cursor={cursor}
1236
+ disabled={isBusy}
1237
+ disabledLabel={
1238
+ synergyEvent !== null
1239
+ ? "(Synergy event running... boardroom locked)"
1240
+ : undefined
1241
+ }
1242
+ width={contentInputWidth}
1243
+ />
1244
+ </Box>
1245
+ <Box paddingLeft={2}>
1246
+ <StatusBar
1247
+ messageCount={msgCount}
1248
+ witticism={witticism}
1249
+ maxWidth={contentStatusWidth}
1250
+ status={isBusy ? "streaming" : deskStatus}
1251
+ compact={isCompact}
1252
+ scrollHint={scrollHint}
1253
+ />
1254
+ </Box>
1255
+ </>
1256
+ )}
1257
+ </Box>
1258
+ </Box>
836
1259
  </Box>
837
1260
  </Box>
838
1261
  </ThemeProvider>