drexler 0.2.14 → 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 {
@@ -50,10 +83,18 @@ import {
50
83
  type SynergyEventDefinition,
51
84
  } from "./SynergyEvent.tsx";
52
85
  import { ThemeProvider } from "./ThemeContext.tsx";
53
- import { TranscriptViewport } from "./TranscriptViewport.tsx";
54
- import { getActiveTheme, THEMES } from "./themes.ts";
86
+ import {
87
+ estimateTranscriptRows,
88
+ TranscriptViewport,
89
+ } from "./TranscriptViewport.tsx";
90
+ import { getActiveTheme } from "./themes.ts";
55
91
 
56
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;
57
98
 
58
99
  export function transcriptRowsForTerminalRows(rows: number): number {
59
100
  return Math.max(1, Math.min(24, rows - TRANSCRIPT_CHROME_ROWS));
@@ -62,15 +103,22 @@ export function transcriptRowsForTerminalRows(rows: number): number {
62
103
  export function nextTranscriptScrollOffset({
63
104
  current,
64
105
  itemCount,
106
+ totalRows,
107
+ visibleRows,
65
108
  direction,
66
109
  step = 3,
67
110
  }: {
68
111
  current: number;
69
- itemCount: number;
112
+ itemCount?: number;
113
+ totalRows?: number;
114
+ visibleRows?: number;
70
115
  direction: "older" | "newer";
71
116
  step?: number;
72
117
  }): number {
73
- 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);
74
122
  if (direction === "older") {
75
123
  return Math.min(maxOffset, current + step);
76
124
  }
@@ -83,6 +131,46 @@ export function shouldRemoveVisibleAssistantForAction(
83
131
  return action.type === "regenerate" && action.removedAssistant;
84
132
  }
85
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
+
86
174
  function pick<T>(arr: readonly T[]): T {
87
175
  if (arr.length === 0) {
88
176
  throw new Error("pick called on empty array");
@@ -136,12 +224,20 @@ export function App({
136
224
  };
137
225
  }, [stdout]);
138
226
  const mode = useMemo(() => pickLayout(cols), [cols]);
139
- const inputWidth = useMemo(() => Math.max(1, cols), [cols]);
140
227
  const chromeWidth = useMemo(() => Math.max(1, cols), [cols]);
141
- const statusBarWidth = inputWidth;
142
228
  const isCompact = mode === "very-narrow";
143
229
  const integratedIntro =
144
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);
145
241
  const introRowBudget =
146
242
  integratedIntro ? (chromeWidth >= 112 ? 14 : chromeWidth >= 72 ? 26 : 6) : 0;
147
243
  const maxTranscriptRows = useMemo(
@@ -197,11 +293,6 @@ export function App({
197
293
  const [witticism, setWitticism] = useState<string>(pick(WITTICISMS));
198
294
  const [model, setModel] = useState<string>(config.model);
199
295
  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
296
  const [deskStatus, setDeskStatus] = useState<"idle" | "error">("idle");
206
297
  const [deskNotice, setDeskNotice] = useState<string | null>(null);
207
298
  const [history, setHistory] = useState<string[]>([]);
@@ -210,6 +301,146 @@ export function App({
210
301
  const [scrollOffset, setScrollOffset] = useState(0);
211
302
  const intro = useIntroAnimation(chromeWidth, integratedIntro);
212
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]);
443
+
213
444
  const paletteItems = useMemo(() => filterPaletteByPrefix(input), [input]);
214
445
  const paletteOpen = paletteItems.length > 0;
215
446
  useEffect(() => {
@@ -220,23 +451,17 @@ export function App({
220
451
  setScrollOffset(0);
221
452
  }, [items.length]);
222
453
 
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
-
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
+ );
236
461
  const scrollHint = useMemo(() => {
237
- if (items.length <= maxTranscriptRows) return undefined;
462
+ if (estimatedTranscriptRows <= visibleTranscriptRows) return undefined;
238
463
  return scrollOffset > 0 ? "PageDown newer" : "PageUp scrollback";
239
- }, [items.length, maxTranscriptRows, scrollOffset]);
464
+ }, [estimatedTranscriptRows, visibleTranscriptRows, scrollOffset]);
240
465
 
241
466
  // throttle streaming updates so React doesn't re-render every token
242
467
  const streamBufRef = useRef("");
@@ -249,6 +474,7 @@ export function App({
249
474
  const exitingRef = useRef(false);
250
475
  const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
251
476
  const synergyTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
477
+ const historyDraftRef = useRef<{ value: string; cursor: number } | null>(null);
252
478
  const flushStream = useCallback(() => {
253
479
  if (!mountedRef.current) return;
254
480
  setStreaming(streamBufRef.current);
@@ -260,6 +486,7 @@ export function App({
260
486
  if (exitingRef.current) return;
261
487
  exitingRef.current = true;
262
488
  abortRef.current?.abort();
489
+ savePetState(petStatsRef.current);
263
490
  if (streamTimerRef.current !== null) {
264
491
  clearTimeout(streamTimerRef.current);
265
492
  streamTimerRef.current = null;
@@ -333,12 +560,10 @@ export function App({
333
560
  if (requestInFlightRef.current) return;
334
561
  requestInFlightRef.current = true;
335
562
  setRequestInFlight(true);
336
- const startedAt = Date.now();
337
563
  try {
338
564
  setThinking(pick(THINKING_LINES));
339
565
  setDeskStatus("idle");
340
566
  setDeskNotice(null);
341
- setFallbackModel(null);
342
567
  streamBufRef.current = "";
343
568
  setStreaming(null);
344
569
  let firstToken = true;
@@ -389,7 +614,6 @@ export function App({
389
614
  }
390
615
  setThinking(null);
391
616
  setStreaming(null);
392
- setLastLatencyMs(Date.now() - startedAt);
393
617
  if (cancelledRef.current) {
394
618
  cancelledRef.current = false;
395
619
  if (result?.content) {
@@ -405,7 +629,6 @@ export function App({
405
629
  if (result.fellBack) {
406
630
  addItem("system", `(fell back to ${result.modelUsed})`);
407
631
  notices.push(`fallback ${result.modelUsed}`);
408
- setFallbackModel(result.modelUsed);
409
632
  }
410
633
  if (detectPersonaDrift(result.content)) {
411
634
  addItem("system", `(persona drift detected — model used 'I')`);
@@ -425,7 +648,6 @@ export function App({
425
648
  setDeskNotice(result?.error ?? "stream error");
426
649
  }
427
650
  setMsgCount(conversation.length);
428
- setTokenCount(conversation.approximateTokens());
429
651
  setWitticism(pick(WITTICISMS));
430
652
  } finally {
431
653
  requestInFlightRef.current = false;
@@ -444,6 +666,134 @@ export function App({
444
666
 
445
667
  const handleSlashWithMutation = useCallback(
446
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
+
447
797
  let captured = "";
448
798
  const mutableConfig: Config = { ...config, model };
449
799
  const action = dispatch(line, {
@@ -453,15 +803,8 @@ export function App({
453
803
  captured += (captured ? "\n" : "") + s;
454
804
  },
455
805
  });
456
- const lower = line.toLowerCase().trim();
457
- if (lower === "/synergy") {
458
- runSynergyEvent();
459
- return;
460
- }
461
806
  if (lower === "/clear" || lower.startsWith("/clear ")) {
462
807
  setItems([]);
463
- setLastLatencyMs(null);
464
- setFallbackModel(null);
465
808
  }
466
809
  if (mutableConfig.model !== model) {
467
810
  setModel(mutableConfig.model);
@@ -498,7 +841,6 @@ export function App({
498
841
  await runLLM(action.instruction);
499
842
  }
500
843
  setMsgCount(conversation.length);
501
- setTokenCount(conversation.approximateTokens());
502
844
  },
503
845
  [
504
846
  addItem,
@@ -506,10 +848,14 @@ export function App({
506
848
  config,
507
849
  activeTheme,
508
850
  model,
851
+ petStats,
852
+ PET_MESSAGES,
509
853
  removeLastAssistantItem,
510
854
  runLLM,
511
855
  runSynergyEvent,
512
856
  triggerExit,
857
+ triggerPetActivity,
858
+ updatePetStats,
513
859
  ],
514
860
  );
515
861
 
@@ -528,13 +874,46 @@ export function App({
528
874
  addItem("user", line);
529
875
  conversation.push("user", line);
530
876
  setMsgCount(conversation.length);
531
- setTokenCount(conversation.approximateTokens());
532
877
  await runLLM();
533
878
  },
534
879
  [addItem, conversation, handleSlashWithMutation, runLLM],
535
880
  );
536
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
+
537
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
+ }
538
917
  const busy =
539
918
  requestInFlightRef.current ||
540
919
  synergyActiveRef.current ||
@@ -565,32 +944,25 @@ export function App({
565
944
  }
566
945
  return;
567
946
  }
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
947
  if (paletteOpen && key.return) {
589
948
  const sel = paletteItems[paletteIdx];
590
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
+ }
591
960
  updateDraft({ value: "", cursor: 0 });
592
961
  setHistoryIdx(null);
593
- void onSubmit(sel.name);
962
+ historyDraftRef.current = null;
963
+ onSubmit(sel.name).catch((err) => {
964
+ reportSubmitError(err);
965
+ });
594
966
  }
595
967
  return;
596
968
  }
@@ -598,6 +970,7 @@ export function App({
598
970
  const submitted = draftRef.current.value;
599
971
  updateDraft({ value: "", cursor: 0 });
600
972
  setHistoryIdx(null);
973
+ historyDraftRef.current = null;
601
974
  const trimmedSubmit = submitted.trim();
602
975
  if (trimmedSubmit.length > 0) {
603
976
  setHistory((prev) => {
@@ -605,7 +978,9 @@ export function App({
605
978
  return next.length > 50 ? next.slice(-50) : next;
606
979
  });
607
980
  }
608
- void onSubmit(submitted);
981
+ onSubmit(submitted).catch((err) => {
982
+ reportSubmitError(err);
983
+ });
609
984
  return;
610
985
  }
611
986
  if (key.ctrl && char === "c") {
@@ -649,10 +1024,18 @@ export function App({
649
1024
  return;
650
1025
  }
651
1026
  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) });
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);
656
1039
  return;
657
1040
  }
658
1041
  if (key.downArrow) {
@@ -661,15 +1044,18 @@ export function App({
661
1044
  return;
662
1045
  }
663
1046
  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
- }
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);
673
1059
  return;
674
1060
  }
675
1061
  if (key.ctrl && char === "a") {
@@ -726,13 +1112,8 @@ export function App({
726
1112
  const headerStatus = isBusy ? "streaming" : deskStatus;
727
1113
  const renderDealDeskHeader = (width: number) => (
728
1114
  <DealDeskHeader
729
- model={model}
730
1115
  mood={mood}
731
1116
  messageCount={msgCount}
732
- themeName={themeName}
733
- approximateTokens={tokenCount}
734
- latencyMs={lastLatencyMs}
735
- fallbackModel={fallbackModel}
736
1117
  status={headerStatus}
737
1118
  compact={isCompact}
738
1119
  notice={!integratedIntro ? deskNotice ?? undefined : undefined}
@@ -741,11 +1122,16 @@ export function App({
741
1122
  />
742
1123
  );
743
1124
  const dealDeskHeader = renderDealDeskHeader(chromeWidth);
744
- const visibleTranscriptRows = synergyEvent
745
- ? Math.max(1, maxTranscriptRows - synergyEventRows(chromeWidth, isCompact))
746
- : maxTranscriptRows;
747
1125
  const introBarColor = introPhaseColor(intro.colorPhase, t);
748
1126
 
1127
+ if (isDead) {
1128
+ return (
1129
+ <ThemeProvider value={activeTheme}>
1130
+ <DeathScreen reason={deathReason} variant={deathVariant} />
1131
+ </ThemeProvider>
1132
+ );
1133
+ }
1134
+
749
1135
  return (
750
1136
  <ThemeProvider value={activeTheme}>
751
1137
  <Box flexDirection="column">
@@ -766,73 +1152,86 @@ export function App({
766
1152
  ) : (
767
1153
  dealDeskHeader
768
1154
  )}
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} />
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
+ />
786
1164
  </Box>
787
1165
  )}
788
- {synergyEvent !== null && (
789
- <SynergyEvent
790
- event={synergyEvent.event}
791
- frame={synergyEvent.frame}
792
- width={chromeWidth}
1166
+ <Box flexDirection="column" flexGrow={1}>
1167
+ <TranscriptViewport
1168
+ items={items}
1169
+ maxRows={visibleTranscriptRows}
1170
+ cols={contentWidth}
793
1171
  compact={isCompact}
1172
+ scrollOffset={scrollOffset}
794
1173
  />
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
- />
1174
+ <Box flexDirection="column">
1175
+ {streaming !== null && (
1176
+ <Box marginBottom={1}>
1177
+ <StreamingMessage content={streaming} width={contentWidth} />
1178
+ </Box>
810
1179
  )}
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}
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}
830
1190
  compact={isCompact}
831
- scrollHint={scrollHint}
832
1191
  />
833
- </Box>
834
- </>
835
- )}
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>
836
1235
  </Box>
837
1236
  </Box>
838
1237
  </ThemeProvider>