drexler 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.7
4
+
5
+ - Stabilized `/synergy` animation layout with fixed row budgeting, a capped centered event panel, and completion only at 100%.
6
+ - Hardened interactive busy-state handling so input stays locked during active LLM requests and synergy events.
7
+ - Added lifecycle and row-budget coverage for the animated synergy flow.
8
+
9
+ ## 0.2.6
10
+
11
+ - Upgraded `/synergy` into a rotating animated Ink event with staged reveals, progress, KPI tickers, and themed finale copy.
12
+ - Added compact synergy rendering and a non-interactive fallback line for classic command dispatch.
13
+
3
14
  ## 0.2.5
4
15
 
5
16
  - Made constrained slash commands open smoother option choosers, with `/theme` showing all theme choices as soon as the command is typed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "CLI chat with Drexler, a corporate-executive AI persona built on OpenRouter Gemma 4 31B.",
5
5
  "license": "MIT",
6
6
  "author": "showOS",
package/src/commands.ts CHANGED
@@ -314,7 +314,9 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
314
314
  };
315
315
 
316
316
  case "synergy":
317
- ctx.print("SYNERGY. You promoted. Award: continued employment.");
317
+ ctx.print(
318
+ "SYNERGY EVENT: alignment protocol completed. Award: continued employment.",
319
+ );
318
320
  return { type: "continue" };
319
321
 
320
322
  case "model":
package/src/ui/App.tsx CHANGED
@@ -37,6 +37,13 @@ import { InputBox } from "./InputBox.tsx";
37
37
  import { StreamingMessage } from "./Message.tsx";
38
38
  import { Spinner } from "./Spinner.tsx";
39
39
  import { StatusBar } from "./StatusBar.tsx";
40
+ import {
41
+ pickSynergyEvent,
42
+ SynergyEvent,
43
+ SYNERGY_EVENT_FRAMES,
44
+ synergyEventRows,
45
+ type SynergyEventDefinition,
46
+ } from "./SynergyEvent.tsx";
40
47
  import { ThemeProvider } from "./ThemeContext.tsx";
41
48
  import { TranscriptViewport } from "./TranscriptViewport.tsx";
42
49
  import { getActiveTheme, THEMES } from "./themes.ts";
@@ -84,6 +91,11 @@ interface ChatItem {
84
91
  content: string;
85
92
  }
86
93
 
94
+ interface ActiveSynergyEvent {
95
+ event: SynergyEventDefinition;
96
+ frame: number;
97
+ }
98
+
87
99
  interface AppProps {
88
100
  conversation: Conversation;
89
101
  config: Config;
@@ -155,6 +167,10 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
155
167
  const cursor = draft.cursor;
156
168
  const [streaming, setStreaming] = useState<string | null>(null);
157
169
  const [thinking, setThinking] = useState<string | null>(null);
170
+ const [requestInFlight, setRequestInFlight] = useState(false);
171
+ const [synergyEvent, setSynergyEvent] = useState<ActiveSynergyEvent | null>(
172
+ null,
173
+ );
158
174
  const [exitMsg, setExitMsg] = useState<string | null>(null);
159
175
  const [witticism, setWitticism] = useState<string>(pick(WITTICISMS));
160
176
  const [model, setModel] = useState<string>(config.model);
@@ -204,9 +220,12 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
204
220
  const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
205
221
  const abortRef = useRef<AbortController | null>(null);
206
222
  const cancelledRef = useRef(false);
223
+ const requestInFlightRef = useRef(false);
224
+ const synergyActiveRef = useRef(false);
207
225
  const mountedRef = useRef(true);
208
226
  const exitingRef = useRef(false);
209
227
  const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
228
+ const synergyTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
210
229
  const flushStream = useCallback(() => {
211
230
  if (!mountedRef.current) return;
212
231
  setStreaming(streamBufRef.current);
@@ -218,6 +237,18 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
218
237
  if (exitingRef.current) return;
219
238
  exitingRef.current = true;
220
239
  abortRef.current?.abort();
240
+ if (streamTimerRef.current !== null) {
241
+ clearTimeout(streamTimerRef.current);
242
+ streamTimerRef.current = null;
243
+ }
244
+ if (synergyTimerRef.current !== null) {
245
+ clearInterval(synergyTimerRef.current);
246
+ synergyTimerRef.current = null;
247
+ }
248
+ requestInFlightRef.current = false;
249
+ synergyActiveRef.current = false;
250
+ setRequestInFlight(false);
251
+ setSynergyEvent(null);
221
252
  setExitMsg(msg);
222
253
  exitTimerRef.current = setTimeout(() => exit(), 50);
223
254
  },
@@ -234,100 +265,151 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
234
265
  [flushStream],
235
266
  );
236
267
 
268
+ const runSynergyEvent = useCallback(() => {
269
+ if (synergyTimerRef.current !== null) {
270
+ clearInterval(synergyTimerRef.current);
271
+ synergyTimerRef.current = null;
272
+ }
273
+
274
+ const event = pickSynergyEvent();
275
+ let frame = 0;
276
+ const finalFrame = SYNERGY_EVENT_FRAMES - 1;
277
+ const holdFrames = 8;
278
+
279
+ setThinking(null);
280
+ setStreaming(null);
281
+ synergyActiveRef.current = true;
282
+ setDeskStatus("idle");
283
+ setDeskNotice("synergy event");
284
+ setSynergyEvent({ event, frame });
285
+
286
+ synergyTimerRef.current = setInterval(() => {
287
+ frame += 1;
288
+ if (!mountedRef.current) return;
289
+
290
+ if (frame <= finalFrame) {
291
+ setSynergyEvent({ event, frame });
292
+ return;
293
+ }
294
+
295
+ if (frame >= finalFrame + holdFrames) {
296
+ if (synergyTimerRef.current !== null) {
297
+ clearInterval(synergyTimerRef.current);
298
+ synergyTimerRef.current = null;
299
+ }
300
+ setSynergyEvent(null);
301
+ synergyActiveRef.current = false;
302
+ setDeskNotice("synergy complete");
303
+ setWitticism(event.finalLine);
304
+ addItem("system", event.transcriptLine);
305
+ }
306
+ }, 60);
307
+ }, [addItem]);
308
+
237
309
  const runLLM = useCallback(async (instruction?: string) => {
310
+ if (requestInFlightRef.current) return;
311
+ requestInFlightRef.current = true;
312
+ setRequestInFlight(true);
238
313
  const startedAt = Date.now();
239
- setThinking(pick(THINKING_LINES));
240
- setDeskStatus("idle");
241
- setDeskNotice(null);
242
- setFallbackModel(null);
243
- streamBufRef.current = "";
244
- setStreaming(null);
245
- let firstToken = true;
246
- abortRef.current = new AbortController();
247
- let result: Awaited<ReturnType<typeof streamChat>> | undefined;
248
- let caughtErr: unknown = null;
249
314
  try {
250
- result = await streamChat({
251
- apiKey: config.apiKey,
252
- model,
253
- fallbackModel: pickFallback(model),
254
- messages: instruction
255
- ? [
256
- ...buildMessagesWithReminder(conversation),
257
- { role: "system", content: instruction },
258
- ]
259
- : buildMessagesWithReminder(conversation),
260
- onToken: (t) => {
261
- if (!mountedRef.current) return;
262
- if (firstToken) {
263
- setThinking(null);
264
- firstToken = false;
265
- }
266
- pushTokenToStream(t);
267
- },
268
- signal: abortRef.current.signal,
269
- fetchFn,
270
- });
271
- } catch (err) {
272
- caughtErr = err;
273
- } finally {
274
- if (streamTimerRef.current !== null) {
275
- clearTimeout(streamTimerRef.current);
276
- streamTimerRef.current = null;
315
+ setThinking(pick(THINKING_LINES));
316
+ setDeskStatus("idle");
317
+ setDeskNotice(null);
318
+ setFallbackModel(null);
319
+ streamBufRef.current = "";
320
+ setStreaming(null);
321
+ let firstToken = true;
322
+ abortRef.current = new AbortController();
323
+ let result: Awaited<ReturnType<typeof streamChat>> | undefined;
324
+ let caughtErr: unknown = null;
325
+ try {
326
+ result = await streamChat({
327
+ apiKey: config.apiKey,
328
+ model,
329
+ fallbackModel: pickFallback(model),
330
+ messages: instruction
331
+ ? [
332
+ ...buildMessagesWithReminder(conversation),
333
+ { role: "system", content: instruction },
334
+ ]
335
+ : buildMessagesWithReminder(conversation),
336
+ onToken: (t) => {
337
+ if (!mountedRef.current || exitingRef.current) return;
338
+ if (firstToken) {
339
+ setThinking(null);
340
+ firstToken = false;
341
+ }
342
+ pushTokenToStream(t);
343
+ },
344
+ signal: abortRef.current.signal,
345
+ fetchFn,
346
+ });
347
+ } catch (err) {
348
+ caughtErr = err;
349
+ } finally {
350
+ if (streamTimerRef.current !== null) {
351
+ clearTimeout(streamTimerRef.current);
352
+ streamTimerRef.current = null;
353
+ }
354
+ abortRef.current = null;
355
+ }
356
+ if (!mountedRef.current || exitingRef.current) return;
357
+ if (caughtErr) {
358
+ const msg = caughtErr instanceof Error ? caughtErr.message : String(caughtErr);
359
+ setThinking(null);
360
+ setStreaming(null);
361
+ addItem("system", `${STREAM_ERROR} [${msg}]`);
362
+ setDeskStatus("error");
363
+ setDeskNotice(msg);
364
+ setMsgCount(conversation.length);
365
+ return;
277
366
  }
278
- abortRef.current = null;
279
- }
280
- if (!mountedRef.current) return;
281
- if (caughtErr) {
282
- const msg = caughtErr instanceof Error ? caughtErr.message : String(caughtErr);
283
367
  setThinking(null);
284
368
  setStreaming(null);
285
- addItem("system", `${STREAM_ERROR} [${msg}]`);
286
- setDeskStatus("error");
287
- setDeskNotice(msg);
288
- setMsgCount(conversation.length);
289
- return;
290
- }
291
- setThinking(null);
292
- setStreaming(null);
293
- setLastLatencyMs(Date.now() - startedAt);
294
- if (cancelledRef.current) {
295
- cancelledRef.current = false;
296
- if (result?.content) {
369
+ setLastLatencyMs(Date.now() - startedAt);
370
+ if (cancelledRef.current) {
371
+ cancelledRef.current = false;
372
+ if (result?.content) {
373
+ conversation.push("assistant", result.content);
374
+ addItem("assistant", result.content);
375
+ }
376
+ addItem("system", "(cancelled — Drexler taking lunch)");
377
+ setDeskNotice("response cancelled");
378
+ } else if (result?.ok) {
297
379
  conversation.push("assistant", result.content);
298
380
  addItem("assistant", result.content);
381
+ const notices: string[] = [];
382
+ if (result.fellBack) {
383
+ addItem("system", `(fell back to ${result.modelUsed})`);
384
+ notices.push(`fallback ${result.modelUsed}`);
385
+ setFallbackModel(result.modelUsed);
386
+ }
387
+ if (detectPersonaDrift(result.content)) {
388
+ addItem("system", `(persona drift detected — model used 'I')`);
389
+ notices.push("persona drift detected");
390
+ }
391
+ setDeskNotice(notices.length > 0 ? notices.join(" · ") : null);
392
+ } else if (result?.interrupted) {
393
+ conversation.push("assistant", result.content);
394
+ addItem("assistant", result.content);
395
+ addItem("system", "(stream interrupted — partial response saved)");
396
+ setDeskStatus("error");
397
+ setDeskNotice("stream interrupted; partial response saved");
398
+ } else {
399
+ const detail = result?.error ? ` [${result.error}]` : "";
400
+ addItem("system", `${STREAM_ERROR}${detail}`);
401
+ setDeskStatus("error");
402
+ setDeskNotice(result?.error ?? "stream error");
299
403
  }
300
- addItem("system", "(cancelled — Drexler taking lunch)");
301
- setDeskNotice("response cancelled");
302
- } else if (result?.ok) {
303
- conversation.push("assistant", result.content);
304
- addItem("assistant", result.content);
305
- const notices: string[] = [];
306
- if (result.fellBack) {
307
- addItem("system", `(fell back to ${result.modelUsed})`);
308
- notices.push(`fallback ${result.modelUsed}`);
309
- setFallbackModel(result.modelUsed);
310
- }
311
- if (detectPersonaDrift(result.content)) {
312
- addItem("system", `(persona drift detected — model used 'I')`);
313
- notices.push("persona drift detected");
404
+ setMsgCount(conversation.length);
405
+ setTokenCount(conversation.approximateTokens());
406
+ setWitticism(pick(WITTICISMS));
407
+ } finally {
408
+ requestInFlightRef.current = false;
409
+ if (mountedRef.current) {
410
+ setRequestInFlight(false);
314
411
  }
315
- setDeskNotice(notices.length > 0 ? notices.join(" · ") : null);
316
- } else if (result?.interrupted) {
317
- conversation.push("assistant", result.content);
318
- addItem("assistant", result.content);
319
- addItem("system", "(stream interrupted — partial response saved)");
320
- setDeskStatus("error");
321
- setDeskNotice("stream interrupted; partial response saved");
322
- } else {
323
- const detail = result?.error ? ` [${result.error}]` : "";
324
- addItem("system", `${STREAM_ERROR}${detail}`);
325
- setDeskStatus("error");
326
- setDeskNotice(result?.error ?? "stream error");
327
412
  }
328
- setMsgCount(conversation.length);
329
- setTokenCount(conversation.approximateTokens());
330
- setWitticism(pick(WITTICISMS));
331
413
  }, [
332
414
  config,
333
415
  model,
@@ -349,6 +431,10 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
349
431
  },
350
432
  });
351
433
  const lower = line.toLowerCase().trim();
434
+ if (lower === "/synergy") {
435
+ runSynergyEvent();
436
+ return;
437
+ }
352
438
  if (lower === "/clear" || lower.startsWith("/clear ")) {
353
439
  setItems([]);
354
440
  setLastLatencyMs(null);
@@ -399,12 +485,14 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
399
485
  model,
400
486
  removeLastAssistantItem,
401
487
  runLLM,
488
+ runSynergyEvent,
402
489
  triggerExit,
403
490
  ],
404
491
  );
405
492
 
406
493
  const onSubmit = useCallback(
407
494
  async (raw: string) => {
495
+ if (requestInFlightRef.current || synergyActiveRef.current) return;
408
496
  const line = raw.trim();
409
497
  if (line === "") {
410
498
  addItem("system", EMPTY_NUDGE);
@@ -424,8 +512,17 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
424
512
  );
425
513
 
426
514
  useInput((char, key) => {
427
- if (streaming !== null || thinking !== null) {
515
+ const busy =
516
+ requestInFlightRef.current ||
517
+ synergyActiveRef.current ||
518
+ streaming !== null ||
519
+ thinking !== null ||
520
+ synergyEvent !== null;
521
+ if (busy) {
428
522
  if (key.escape) {
523
+ if (synergyActiveRef.current || synergyEvent !== null) {
524
+ return;
525
+ }
429
526
  cancelledRef.current = true;
430
527
  abortRef.current?.abort();
431
528
  return;
@@ -593,11 +690,20 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
593
690
  if (exitTimerRef.current !== null) {
594
691
  clearTimeout(exitTimerRef.current);
595
692
  }
693
+ if (synergyTimerRef.current !== null) {
694
+ clearInterval(synergyTimerRef.current);
695
+ }
696
+ requestInFlightRef.current = false;
697
+ synergyActiveRef.current = false;
596
698
  };
597
699
  }, []);
598
700
 
599
- const isBusy = streaming !== null || thinking !== null;
701
+ const isBusy =
702
+ requestInFlight || streaming !== null || thinking !== null || synergyEvent !== null;
600
703
  const headerStatus = isBusy ? "streaming" : deskStatus;
704
+ const visibleTranscriptRows = synergyEvent
705
+ ? Math.max(1, maxTranscriptRows - synergyEventRows(chromeWidth, isCompact))
706
+ : maxTranscriptRows;
601
707
 
602
708
  return (
603
709
  <ThemeProvider value={activeTheme}>
@@ -617,7 +723,7 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
617
723
  />
618
724
  <TranscriptViewport
619
725
  items={items}
620
- maxRows={maxTranscriptRows}
726
+ maxRows={visibleTranscriptRows}
621
727
  cols={chromeWidth}
622
728
  compact={isCompact}
623
729
  scrollOffset={scrollOffset}
@@ -634,6 +740,14 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
634
740
  <Spinner label={thinking} width={chromeWidth} />
635
741
  </Box>
636
742
  )}
743
+ {synergyEvent !== null && (
744
+ <SynergyEvent
745
+ event={synergyEvent.event}
746
+ frame={synergyEvent.frame}
747
+ width={chromeWidth}
748
+ compact={isCompact}
749
+ />
750
+ )}
637
751
  {exitMsg !== null ? (
638
752
  <Box paddingX={1} marginBottom={1}>
639
753
  <Text color={t.primaryLight} bold>
@@ -654,6 +768,11 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
654
768
  value={input}
655
769
  cursor={cursor}
656
770
  disabled={isBusy}
771
+ disabledLabel={
772
+ synergyEvent !== null
773
+ ? "(Synergy event running... boardroom locked)"
774
+ : undefined
775
+ }
657
776
  width={inputWidth}
658
777
  />
659
778
  </Box>
@@ -8,6 +8,7 @@ interface Props {
8
8
  cursor: number;
9
9
  disabled: boolean;
10
10
  width: number;
11
+ disabledLabel?: string;
11
12
  }
12
13
 
13
14
  const PROMPT_WIDTH = 2;
@@ -80,13 +81,19 @@ function fitPlainText(chars: string[], cursor: number, maxWidth: number): string
80
81
  return out || " ";
81
82
  }
82
83
 
83
- function InputBoxInner({ value, cursor, disabled, width }: Props) {
84
+ function InputBoxInner({
85
+ value,
86
+ cursor,
87
+ disabled,
88
+ width,
89
+ disabledLabel,
90
+ }: Props) {
84
91
  const t = useTheme();
85
92
  const chars = splitGraphemes(value);
86
93
  const safeCursor = clampCursor(value, cursor);
87
94
  const boxWidth = Math.max(1, width);
88
95
  const inputBudget = Math.max(1, boxWidth - BOX_CHROME_WIDTH - PROMPT_WIDTH);
89
- const disabledText = "(Drexler thinking... ESC to cancel)";
96
+ const disabledText = disabledLabel ?? "(Drexler thinking... ESC to cancel)";
90
97
  const window = fitWindow(chars, safeCursor, inputBudget);
91
98
  const visible = chars.slice(window.start, window.end);
92
99
  const visibleCursor = clamp(safeCursor - window.start, 0, visible.length);
@@ -0,0 +1,300 @@
1
+ import { Box, Text } from "ink";
2
+ import { memo } from "react";
3
+ import { displayWidth, fitDisplayText } from "./graphemes.ts";
4
+ import { useTheme } from "./ThemeContext.tsx";
5
+
6
+ export interface SynergyEventDefinition {
7
+ id: string;
8
+ title: string;
9
+ subtitle: string;
10
+ art: readonly string[];
11
+ stages: readonly string[];
12
+ kpis: readonly string[];
13
+ finalLine: string;
14
+ transcriptLine: string;
15
+ }
16
+
17
+ export const SYNERGY_EVENT_FRAMES = 28;
18
+ const FULL_EVENT_WIDTH = 88;
19
+ const FULL_EVENT_ROWS = 12;
20
+ const FULL_EVENT_ART_ROWS = 4;
21
+
22
+ export const SYNERGY_EVENTS: readonly SynergyEventDefinition[] = [
23
+ {
24
+ id: "alignment-protocol",
25
+ title: "ALIGNMENT PROTOCOL",
26
+ subtitle: "cross-functional theater detected",
27
+ art: [
28
+ " ██████╗ ██╗ ██╗███╗ ██╗",
29
+ " ██╔════╝ ╚██╗ ██╔╝████╗ ██║",
30
+ " ╚█████╗ ╚████╔╝ ██╔██╗ ██║",
31
+ " ╚═══██╗ ╚██╔╝ ██║╚██╗██║",
32
+ " ██████╔╝ ██║ ██║ ╚████║",
33
+ " ╚═════╝ ╚═╝ ╚═╝ ╚═══╝",
34
+ ],
35
+ stages: [
36
+ "initiating alignment protocol",
37
+ "harmonizing action items",
38
+ "converting meetings into margin",
39
+ "minting provisional shareholder value",
40
+ ],
41
+ kpis: [
42
+ "EBITDA +0.4%",
43
+ "morale provisionally approved",
44
+ "consultants +7",
45
+ "clarity -3",
46
+ ],
47
+ finalLine:
48
+ "Synergy achieved. Headcount unchanged. Morale amortized.",
49
+ transcriptLine: "SYNERGY EVENT: shareholder value allegedly unlocked.",
50
+ },
51
+ {
52
+ id: "boardroom-alert",
53
+ title: "BOARDROOM ALERT",
54
+ subtitle: "value creation siren armed",
55
+ art: [
56
+ " [!] BOARDROOM ALERT",
57
+ " [!] ALIGNMENT DETECTED",
58
+ " [!] VALUE CREATION IMMINENT",
59
+ " [!] ASK NO FOLLOW-UP QUESTIONS",
60
+ ],
61
+ stages: [
62
+ "paging senior stakeholders",
63
+ "escalating morale to committee",
64
+ "routing accountability offshore",
65
+ "closing the loop with no loop",
66
+ ],
67
+ kpis: [
68
+ "risk committee awake",
69
+ "action items multiplying",
70
+ "status: billable",
71
+ "decision rights unclear",
72
+ ],
73
+ finalLine: "Drexler approves synergy. Nobody asks what changed.",
74
+ transcriptLine: "SYNERGY EVENT: boardroom siren produced measurable vibes.",
75
+ },
76
+ {
77
+ id: "briefcase-cameo",
78
+ title: "BRIEFCASE CAMEO",
79
+ subtitle: "executive artifact opening",
80
+ art: [
81
+ " _________",
82
+ " _/ ___ \\_",
83
+ " | $ $ |",
84
+ " | ───┬─── |",
85
+ " |_____|_______|",
86
+ " / | \\",
87
+ ],
88
+ stages: [
89
+ "unlocking sealed mandate",
90
+ "counting invisible efficiencies",
91
+ "deploying tasteful corporate sparkle",
92
+ "reclassifying excitement as asset",
93
+ ],
94
+ kpis: [
95
+ "briefcase yield +12 bps",
96
+ "sparkle reserve funded",
97
+ "memo density rising",
98
+ "bonus pool unchanged",
99
+ ],
100
+ finalLine: "Briefcase open. Synergy escaped. Legal says it was planned.",
101
+ transcriptLine: "SYNERGY EVENT: briefcase opened and released approved optimism.",
102
+ },
103
+ {
104
+ id: "achievement-unlocked",
105
+ title: "ACHIEVEMENT UNLOCKED",
106
+ subtitle: "cross-functional theater",
107
+ art: [
108
+ " ╔══════════════════╗",
109
+ " ║ DEAL TROPHY +1 ║",
110
+ " ╚══════════════════╝",
111
+ " Reward: meeting",
112
+ " Status: billable",
113
+ ],
114
+ stages: [
115
+ "checking performance conditions",
116
+ "unlocking meeting about meeting",
117
+ "allocating credit to leadership",
118
+ "filing victory under recurring revenue",
119
+ ],
120
+ kpis: [
121
+ "achievement: unlocked",
122
+ "reward: one calendar invite",
123
+ "prestige +8",
124
+ "substance pending",
125
+ ],
126
+ finalLine: "Achievement unlocked: Cross-Functional Theater.",
127
+ transcriptLine: "SYNERGY EVENT: achievement unlocked, substance pending.",
128
+ },
129
+ ];
130
+
131
+ export function pickSynergyEvent(
132
+ random: () => number = Math.random,
133
+ ): SynergyEventDefinition {
134
+ const idx = Math.min(
135
+ SYNERGY_EVENTS.length - 1,
136
+ Math.floor(random() * SYNERGY_EVENTS.length),
137
+ );
138
+ return SYNERGY_EVENTS[idx]!;
139
+ }
140
+
141
+ function frameProgress(frame: number): number {
142
+ return Math.max(0, Math.min(1, frame / (SYNERGY_EVENT_FRAMES - 1)));
143
+ }
144
+
145
+ function bar(progress: number, width: number): string {
146
+ const safeWidth = Math.max(1, width);
147
+ const filled = Math.max(0, Math.min(safeWidth, Math.round(progress * safeWidth)));
148
+ return `${"█".repeat(filled)}${"░".repeat(safeWidth - filled)}`;
149
+ }
150
+
151
+ function stageAt(event: SynergyEventDefinition, frame: number): string {
152
+ const progress = frameProgress(frame);
153
+ const idx = Math.min(
154
+ event.stages.length - 1,
155
+ Math.floor(progress * event.stages.length),
156
+ );
157
+ return event.stages[idx]!;
158
+ }
159
+
160
+ function visibleArt(event: SynergyEventDefinition, frame: number): readonly string[] {
161
+ const progress = frameProgress(frame);
162
+ const count = Math.max(
163
+ 1,
164
+ Math.ceil(progress * Math.min(event.art.length, FULL_EVENT_ART_ROWS)),
165
+ );
166
+ return event.art.slice(0, count);
167
+ }
168
+
169
+ function kpiAt(event: SynergyEventDefinition, frame: number): string {
170
+ return event.kpis[Math.floor(frame / 3) % event.kpis.length]!;
171
+ }
172
+
173
+ interface Props {
174
+ event: SynergyEventDefinition;
175
+ frame: number;
176
+ width?: number;
177
+ compact?: boolean;
178
+ }
179
+
180
+ function SynergyEventInner({
181
+ event,
182
+ frame,
183
+ width = 80,
184
+ compact = false,
185
+ }: Props) {
186
+ const t = useTheme();
187
+ const safeWidth = Math.max(1, Math.floor(width));
188
+ const progress = frameProgress(frame);
189
+ const done = frame >= SYNERGY_EVENT_FRAMES - 1;
190
+ const tiny = safeWidth < 38 || compact;
191
+
192
+ if (tiny) {
193
+ const label = done ? event.finalLine : stageAt(event, frame);
194
+ const miniBarWidth = Math.max(4, Math.min(18, safeWidth - 12));
195
+ const line = `SYNC ${bar(progress, miniBarWidth)} ${label}`;
196
+ return (
197
+ <Box width={safeWidth} flexShrink={1}>
198
+ <Text color={done ? t.primaryLight : t.warning} bold wrap="truncate">
199
+ {fitDisplayText(line, safeWidth)}
200
+ </Text>
201
+ </Box>
202
+ );
203
+ }
204
+
205
+ const panelWidth = Math.min(safeWidth, FULL_EVENT_WIDTH);
206
+ const innerWidth = Math.max(1, panelWidth - 4);
207
+ const title = `${event.title} · ${event.subtitle}`;
208
+ const progressWidth = Math.max(8, Math.min(34, innerWidth - 18));
209
+ const progressPct = `${Math.round(progress * 100)
210
+ .toString()
211
+ .padStart(3, " ")}%`;
212
+ const artWidth = Math.max(1, innerWidth - 4);
213
+ const kpi = kpiAt(event, frame);
214
+
215
+ return (
216
+ <Box width={safeWidth} justifyContent="center" flexShrink={1}>
217
+ <Box
218
+ flexDirection="column"
219
+ borderStyle="round"
220
+ borderColor={done ? t.primaryLight : t.warning}
221
+ paddingX={1}
222
+ width={panelWidth}
223
+ flexShrink={1}
224
+ >
225
+ <Box>
226
+ <Text color={t.warning} bold>
227
+ SYNERGY EVENT
228
+ </Text>
229
+ <Text color={t.primaryDim}> ─ </Text>
230
+ <Text color={t.dim} wrap="truncate">
231
+ {fitDisplayText(title, Math.max(1, innerWidth - 18))}
232
+ </Text>
233
+ </Box>
234
+ <Box flexDirection="column">
235
+ {event.art.slice(0, FULL_EVENT_ART_ROWS).map((line, idx) => {
236
+ const revealed = idx < visibleArt(event, frame).length;
237
+ return (
238
+ <Text
239
+ key={`${event.id}-${idx}`}
240
+ color={revealed ? t.primaryLight : t.primaryDim}
241
+ wrap="truncate"
242
+ >
243
+ {revealed ? fitDisplayText(line, artWidth) : " "}
244
+ </Text>
245
+ );
246
+ })}
247
+ </Box>
248
+ <Box>
249
+ <Text color={t.primaryDim}>[</Text>
250
+ <Text color={done ? t.primaryLight : t.warning}>
251
+ {bar(progress, progressWidth)}
252
+ </Text>
253
+ <Text color={t.primaryDim}>] </Text>
254
+ <Text color={t.dim}>{progressPct}</Text>
255
+ </Box>
256
+ <Box>
257
+ <Text color={t.primaryLight} bold>
258
+ ◆{" "}
259
+ </Text>
260
+ <Text color={t.text} wrap="truncate">
261
+ {fitDisplayText(stageAt(event, frame), Math.max(1, innerWidth - 4))}
262
+ </Text>
263
+ </Box>
264
+ <Box>
265
+ <Text color={t.primaryDim}>ticker </Text>
266
+ <Text color={t.warning} wrap="truncate">
267
+ {fitDisplayText(kpi, Math.max(1, innerWidth - 9))}
268
+ </Text>
269
+ </Box>
270
+ <Box>
271
+ <Text color={done ? t.primaryLight : t.primaryDim} bold={done} wrap="truncate">
272
+ {fitDisplayText(
273
+ done ? event.finalLine : "awaiting committee approval...",
274
+ Math.max(1, innerWidth - 2),
275
+ )}
276
+ </Text>
277
+ </Box>
278
+ </Box>
279
+ </Box>
280
+ );
281
+ }
282
+
283
+ export const SynergyEvent = memo(SynergyEventInner);
284
+
285
+ export function synergyEventRows(width: number, compact = false): number {
286
+ if (compact || width < 38) return 1;
287
+ return FULL_EVENT_ROWS;
288
+ }
289
+
290
+ export function synergyEventMaxRowWidth(
291
+ rendered: string,
292
+ stripAnsi: (s: string) => string = (s) => s,
293
+ ): number {
294
+ return Math.max(
295
+ 0,
296
+ ...stripAnsi(rendered)
297
+ .split("\n")
298
+ .map((row) => displayWidth(row)),
299
+ );
300
+ }