drexler 0.2.6 → 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,11 @@
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
+
3
9
  ## 0.2.6
4
10
 
5
11
  - Upgraded `/synergy` into a rotating animated Ink event with staged reveals, progress, KPI tickers, and themed finale copy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.6",
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/ui/App.tsx CHANGED
@@ -167,6 +167,7 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
167
167
  const cursor = draft.cursor;
168
168
  const [streaming, setStreaming] = useState<string | null>(null);
169
169
  const [thinking, setThinking] = useState<string | null>(null);
170
+ const [requestInFlight, setRequestInFlight] = useState(false);
170
171
  const [synergyEvent, setSynergyEvent] = useState<ActiveSynergyEvent | null>(
171
172
  null,
172
173
  );
@@ -219,6 +220,8 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
219
220
  const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
220
221
  const abortRef = useRef<AbortController | null>(null);
221
222
  const cancelledRef = useRef(false);
223
+ const requestInFlightRef = useRef(false);
224
+ const synergyActiveRef = useRef(false);
222
225
  const mountedRef = useRef(true);
223
226
  const exitingRef = useRef(false);
224
227
  const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -234,6 +237,18 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
234
237
  if (exitingRef.current) return;
235
238
  exitingRef.current = true;
236
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);
237
252
  setExitMsg(msg);
238
253
  exitTimerRef.current = setTimeout(() => exit(), 50);
239
254
  },
@@ -263,6 +278,7 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
263
278
 
264
279
  setThinking(null);
265
280
  setStreaming(null);
281
+ synergyActiveRef.current = true;
266
282
  setDeskStatus("idle");
267
283
  setDeskNotice("synergy event");
268
284
  setSynergyEvent({ event, frame });
@@ -282,7 +298,8 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
282
298
  synergyTimerRef.current = null;
283
299
  }
284
300
  setSynergyEvent(null);
285
- setDeskNotice("synergy logged");
301
+ synergyActiveRef.current = false;
302
+ setDeskNotice("synergy complete");
286
303
  setWitticism(event.finalLine);
287
304
  addItem("system", event.transcriptLine);
288
305
  }
@@ -290,99 +307,109 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
290
307
  }, [addItem]);
291
308
 
292
309
  const runLLM = useCallback(async (instruction?: string) => {
310
+ if (requestInFlightRef.current) return;
311
+ requestInFlightRef.current = true;
312
+ setRequestInFlight(true);
293
313
  const startedAt = Date.now();
294
- setThinking(pick(THINKING_LINES));
295
- setDeskStatus("idle");
296
- setDeskNotice(null);
297
- setFallbackModel(null);
298
- streamBufRef.current = "";
299
- setStreaming(null);
300
- let firstToken = true;
301
- abortRef.current = new AbortController();
302
- let result: Awaited<ReturnType<typeof streamChat>> | undefined;
303
- let caughtErr: unknown = null;
304
314
  try {
305
- result = await streamChat({
306
- apiKey: config.apiKey,
307
- model,
308
- fallbackModel: pickFallback(model),
309
- messages: instruction
310
- ? [
311
- ...buildMessagesWithReminder(conversation),
312
- { role: "system", content: instruction },
313
- ]
314
- : buildMessagesWithReminder(conversation),
315
- onToken: (t) => {
316
- if (!mountedRef.current) return;
317
- if (firstToken) {
318
- setThinking(null);
319
- firstToken = false;
320
- }
321
- pushTokenToStream(t);
322
- },
323
- signal: abortRef.current.signal,
324
- fetchFn,
325
- });
326
- } catch (err) {
327
- caughtErr = err;
328
- } finally {
329
- if (streamTimerRef.current !== null) {
330
- clearTimeout(streamTimerRef.current);
331
- 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;
332
366
  }
333
- abortRef.current = null;
334
- }
335
- if (!mountedRef.current) return;
336
- if (caughtErr) {
337
- const msg = caughtErr instanceof Error ? caughtErr.message : String(caughtErr);
338
367
  setThinking(null);
339
368
  setStreaming(null);
340
- addItem("system", `${STREAM_ERROR} [${msg}]`);
341
- setDeskStatus("error");
342
- setDeskNotice(msg);
343
- setMsgCount(conversation.length);
344
- return;
345
- }
346
- setThinking(null);
347
- setStreaming(null);
348
- setLastLatencyMs(Date.now() - startedAt);
349
- if (cancelledRef.current) {
350
- cancelledRef.current = false;
351
- 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) {
352
379
  conversation.push("assistant", result.content);
353
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");
354
403
  }
355
- addItem("system", "(cancelled — Drexler taking lunch)");
356
- setDeskNotice("response cancelled");
357
- } else if (result?.ok) {
358
- conversation.push("assistant", result.content);
359
- addItem("assistant", result.content);
360
- const notices: string[] = [];
361
- if (result.fellBack) {
362
- addItem("system", `(fell back to ${result.modelUsed})`);
363
- notices.push(`fallback ${result.modelUsed}`);
364
- setFallbackModel(result.modelUsed);
365
- }
366
- if (detectPersonaDrift(result.content)) {
367
- addItem("system", `(persona drift detected — model used 'I')`);
368
- 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);
369
411
  }
370
- setDeskNotice(notices.length > 0 ? notices.join(" · ") : null);
371
- } else if (result?.interrupted) {
372
- conversation.push("assistant", result.content);
373
- addItem("assistant", result.content);
374
- addItem("system", "(stream interrupted — partial response saved)");
375
- setDeskStatus("error");
376
- setDeskNotice("stream interrupted; partial response saved");
377
- } else {
378
- const detail = result?.error ? ` [${result.error}]` : "";
379
- addItem("system", `${STREAM_ERROR}${detail}`);
380
- setDeskStatus("error");
381
- setDeskNotice(result?.error ?? "stream error");
382
412
  }
383
- setMsgCount(conversation.length);
384
- setTokenCount(conversation.approximateTokens());
385
- setWitticism(pick(WITTICISMS));
386
413
  }, [
387
414
  config,
388
415
  model,
@@ -465,6 +492,7 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
465
492
 
466
493
  const onSubmit = useCallback(
467
494
  async (raw: string) => {
495
+ if (requestInFlightRef.current || synergyActiveRef.current) return;
468
496
  const line = raw.trim();
469
497
  if (line === "") {
470
498
  addItem("system", EMPTY_NUDGE);
@@ -484,9 +512,15 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
484
512
  );
485
513
 
486
514
  useInput((char, key) => {
487
- if (streaming !== null || thinking !== null || synergyEvent !== null) {
515
+ const busy =
516
+ requestInFlightRef.current ||
517
+ synergyActiveRef.current ||
518
+ streaming !== null ||
519
+ thinking !== null ||
520
+ synergyEvent !== null;
521
+ if (busy) {
488
522
  if (key.escape) {
489
- if (synergyEvent !== null) {
523
+ if (synergyActiveRef.current || synergyEvent !== null) {
490
524
  return;
491
525
  }
492
526
  cancelledRef.current = true;
@@ -659,10 +693,13 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
659
693
  if (synergyTimerRef.current !== null) {
660
694
  clearInterval(synergyTimerRef.current);
661
695
  }
696
+ requestInFlightRef.current = false;
697
+ synergyActiveRef.current = false;
662
698
  };
663
699
  }, []);
664
700
 
665
- const isBusy = streaming !== null || thinking !== null || synergyEvent !== null;
701
+ const isBusy =
702
+ requestInFlight || streaming !== null || thinking !== null || synergyEvent !== null;
666
703
  const headerStatus = isBusy ? "streaming" : deskStatus;
667
704
  const visibleTranscriptRows = synergyEvent
668
705
  ? Math.max(1, maxTranscriptRows - synergyEventRows(chromeWidth, isCompact))
@@ -15,6 +15,9 @@ export interface SynergyEventDefinition {
15
15
  }
16
16
 
17
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;
18
21
 
19
22
  export const SYNERGY_EVENTS: readonly SynergyEventDefinition[] = [
20
23
  {
@@ -67,7 +70,7 @@ export const SYNERGY_EVENTS: readonly SynergyEventDefinition[] = [
67
70
  "status: billable",
68
71
  "decision rights unclear",
69
72
  ],
70
- finalLine: "Drexler approve synergy. Nobody ask what changed.",
73
+ finalLine: "Drexler approves synergy. Nobody asks what changed.",
71
74
  transcriptLine: "SYNERGY EVENT: boardroom siren produced measurable vibes.",
72
75
  },
73
76
  {
@@ -156,7 +159,10 @@ function stageAt(event: SynergyEventDefinition, frame: number): string {
156
159
 
157
160
  function visibleArt(event: SynergyEventDefinition, frame: number): readonly string[] {
158
161
  const progress = frameProgress(frame);
159
- const count = Math.max(1, Math.ceil(progress * event.art.length));
162
+ const count = Math.max(
163
+ 1,
164
+ Math.ceil(progress * Math.min(event.art.length, FULL_EVENT_ART_ROWS)),
165
+ );
160
166
  return event.art.slice(0, count);
161
167
  }
162
168
 
@@ -180,7 +186,7 @@ function SynergyEventInner({
180
186
  const t = useTheme();
181
187
  const safeWidth = Math.max(1, Math.floor(width));
182
188
  const progress = frameProgress(frame);
183
- const done = progress >= 0.94;
189
+ const done = frame >= SYNERGY_EVENT_FRAMES - 1;
184
190
  const tiny = safeWidth < 38 || compact;
185
191
 
186
192
  if (tiny) {
@@ -196,70 +202,80 @@ function SynergyEventInner({
196
202
  );
197
203
  }
198
204
 
199
- const innerWidth = Math.max(1, safeWidth - 4);
205
+ const panelWidth = Math.min(safeWidth, FULL_EVENT_WIDTH);
206
+ const innerWidth = Math.max(1, panelWidth - 4);
200
207
  const title = `${event.title} · ${event.subtitle}`;
201
208
  const progressWidth = Math.max(8, Math.min(34, innerWidth - 18));
202
209
  const progressPct = `${Math.round(progress * 100)
203
210
  .toString()
204
211
  .padStart(3, " ")}%`;
205
- const artWidth = Math.max(1, innerWidth - 2);
212
+ const artWidth = Math.max(1, innerWidth - 4);
206
213
  const kpi = kpiAt(event, frame);
207
214
 
208
215
  return (
209
- <Box
210
- flexDirection="column"
211
- borderStyle="round"
212
- borderColor={done ? t.primaryLight : t.warning}
213
- paddingX={1}
214
- marginBottom={1}
215
- width={safeWidth}
216
- flexShrink={1}
217
- >
218
- <Box>
219
- <Text color={t.warning} bold>
220
- SYNERGY EVENT
221
- </Text>
222
- <Text color={t.primaryDim}> ─ </Text>
223
- <Text color={t.dim} wrap="truncate">
224
- {fitDisplayText(title, Math.max(1, innerWidth - 16))}
225
- </Text>
226
- </Box>
227
- <Box marginTop={1} flexDirection="column">
228
- {visibleArt(event, frame).map((line, idx) => (
229
- <Text key={`${event.id}-${idx}`} color={t.primaryLight} wrap="truncate">
230
- {fitDisplayText(line, artWidth)}
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
231
228
  </Text>
232
- ))}
233
- </Box>
234
- <Box marginTop={1}>
235
- <Text color={t.primaryDim}>[</Text>
236
- <Text color={done ? t.primaryLight : t.warning}>
237
- {bar(progress, progressWidth)}
238
- </Text>
239
- <Text color={t.primaryDim}>] </Text>
240
- <Text color={t.dim}>{progressPct}</Text>
241
- </Box>
242
- <Box>
243
- <Text color={t.primaryLight} bold>
244
- ◆{" "}
245
- </Text>
246
- <Text color={t.text} wrap="truncate">
247
- {fitDisplayText(stageAt(event, frame), Math.max(1, innerWidth - 2))}
248
- </Text>
249
- </Box>
250
- <Box>
251
- <Text color={t.primaryDim}>ticker </Text>
252
- <Text color={t.warning} wrap="truncate">
253
- {fitDisplayText(kpi, Math.max(1, innerWidth - 7))}
254
- </Text>
255
- </Box>
256
- {done ? (
257
- <Box marginTop={1}>
258
- <Text color={t.primaryLight} bold wrap="truncate">
259
- {fitDisplayText(event.finalLine, innerWidth)}
229
+ <Text color={t.primaryDim}> ─ </Text>
230
+ <Text color={t.dim} wrap="truncate">
231
+ {fitDisplayText(title, Math.max(1, innerWidth - 18))}
260
232
  </Text>
261
233
  </Box>
262
- ) : null}
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>
263
279
  </Box>
264
280
  );
265
281
  }
@@ -268,7 +284,7 @@ export const SynergyEvent = memo(SynergyEventInner);
268
284
 
269
285
  export function synergyEventRows(width: number, compact = false): number {
270
286
  if (compact || width < 38) return 1;
271
- return 12;
287
+ return FULL_EVENT_ROWS;
272
288
  }
273
289
 
274
290
  export function synergyEventMaxRowWidth(