@pentoshi/clai 2.0.53 → 2.0.54

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/dist/tui/App.js CHANGED
@@ -116,6 +116,11 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
116
116
  const [selected, setSelected] = useState(0);
117
117
  const [secretRequest, setSecretRequest] = useState();
118
118
  const secretResolver = useRef();
119
+ // Provider picker selection is asynchronous when a key is required. Keep
120
+ // repeated Enter events (or a fast double-submit) from opening a second
121
+ // secret prompt before the first activation has finished persisting.
122
+ const providerActivationRef = useRef(undefined);
123
+ const exitRef = useRef(undefined);
119
124
  const [scroll, setScroll] = useState(0); // lines scrolled up from bottom
120
125
  const [compacting, setCompacting] = useState(false);
121
126
  const [compactStartedAt, setCompactStartedAt] = useState(undefined);
@@ -139,6 +144,7 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
139
144
  const titleRef = useRef({
140
145
  titledAtUserCount: 0,
141
146
  inFlight: false,
147
+ requestId: 0,
142
148
  });
143
149
  const compactAbortRef = useRef(undefined);
144
150
  const cancelCompaction = useCallback(() => {
@@ -185,16 +191,23 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
185
191
  setDefaultMode("agent");
186
192
  dispatch({ type: "notice", level: "info", text: "mode → agent" });
187
193
  }, []),
194
+ onForceExit: useCallback(() => exitRef.current?.(), []),
188
195
  });
189
196
  const exitTui = useCallback(() => {
190
- void (async () => {
191
- const messages = runner.getMessages();
192
- if (!noHistory && !getConfig().privateMode && messages.length > 0) {
193
- await upsertSession(runner.getSession().sessionId, messages, undefined, state.items).catch(() => undefined);
194
- }
195
- exit();
196
- })();
197
- }, [exit, noHistory, runner, state.items]);
197
+ cancelCompaction();
198
+ runner.abort();
199
+ if (autosaveTimer.current) {
200
+ clearTimeout(autosaveTimer.current);
201
+ autosaveTimer.current = undefined;
202
+ }
203
+ const messages = runner.getMessages();
204
+ if (!noHistory && !getConfig().privateMode && messages.length > 0) {
205
+ // Shutdown must not wait for a slow history backend.
206
+ void upsertSession(runner.getSession().sessionId, messages, undefined, latestItems.current).catch(() => undefined);
207
+ }
208
+ exit();
209
+ }, [cancelCompaction, exit, noHistory, runner]);
210
+ exitRef.current = exitTui;
198
211
  // Generate (and later refresh) a concise, model-written title for the
199
212
  // active session. Runs only when a real exchange exists, throttles by user
200
213
  // turn count, and guards against overlapping requests and session resets.
@@ -215,24 +228,34 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
215
228
  if (!shouldGenerate)
216
229
  return;
217
230
  titleRef.current.inFlight = true;
231
+ const requestId = ++titleRef.current.requestId;
218
232
  const targetCount = userCount;
219
233
  const ctx = ctxRef.current;
234
+ // Keep immutable snapshots. The user may start `/new`, resume another
235
+ // session, or exit while the title request is in flight.
236
+ const titleMessages = messages.map((message) => ({ ...message }));
237
+ const titleTranscript = latestItems.current.map((item) => ({ ...item }));
220
238
  void generateSessionTitle(messages, {
221
239
  provider: ctx.provider,
222
240
  model: ctx.model,
223
241
  })
224
242
  .then((title) => {
225
- titleRef.current.inFlight = false;
243
+ if (titleRef.current.requestId === requestId) {
244
+ titleRef.current.inFlight = false;
245
+ }
226
246
  if (!title)
227
247
  return;
228
- // Discard if the user started a new session while we were waiting.
229
- if (runner.getSession().sessionId !== sessionId)
230
- return;
231
- titleRef.current.titledAtUserCount = targetCount;
232
- void upsertSession(sessionId, runner.getMessages(), title, latestItems.current).catch(() => undefined);
248
+ if (titleRef.current.requestId === requestId) {
249
+ titleRef.current.titledAtUserCount = targetCount;
250
+ }
251
+ // Persist against the original session, not whatever session is
252
+ // currently visible in the TUI.
253
+ void upsertSession(sessionId, titleMessages, title, titleTranscript).catch(() => undefined);
233
254
  })
234
255
  .catch(() => {
235
- titleRef.current.inFlight = false;
256
+ if (titleRef.current.requestId === requestId) {
257
+ titleRef.current.inFlight = false;
258
+ }
236
259
  });
237
260
  }, [noHistory, runner]);
238
261
  useEffect(() => {
@@ -428,43 +451,64 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
428
451
  });
429
452
  }, [model, provider]);
430
453
  const activateProvider = useCallback(async (next) => {
431
- const configured = next === "ollama" ||
432
- Boolean(envValue(next)) ||
433
- Boolean((await getProviderSecret(next)).value);
434
- if (!configured) {
435
- const key = await requestSecret({
436
- title: `${next} API key`,
437
- prompt: `No API key is configured for ${next}. Enter it now to activate this provider.`,
438
- });
439
- if (!key) {
440
- dispatch({
441
- type: "notice",
442
- level: "warn",
443
- text: `provider unchanged · ${next} needs an API key`,
444
- });
445
- return;
446
- }
447
- const trimmedKey = key.trim();
448
- if (!getProvider(next).validateKey(trimmedKey)) {
449
- dispatch({
450
- type: "notice",
451
- level: "warn",
452
- text: `invalid API key format for ${next}`,
454
+ if (providerActivationRef.current)
455
+ return;
456
+ providerActivationRef.current = next;
457
+ // Unmount the picker before opening the secret prompt. This makes the
458
+ // prompt the sole owner of stdin while activation is in progress.
459
+ setOverlay({ kind: "none" });
460
+ try {
461
+ const configured = next === "ollama" ||
462
+ Boolean(envValue(next)) ||
463
+ Boolean((await getProviderSecret(next)).value);
464
+ if (!configured) {
465
+ const key = await requestSecret({
466
+ title: `${next} API key`,
467
+ prompt: `No API key is configured for ${next}. Enter it now to activate this provider.`,
453
468
  });
454
- return;
469
+ if (!key) {
470
+ dispatch({
471
+ type: "notice",
472
+ level: "warn",
473
+ text: `provider unchanged · ${next} needs an API key`,
474
+ });
475
+ return;
476
+ }
477
+ const trimmedKey = key.trim();
478
+ if (!getProvider(next).validateKey(trimmedKey)) {
479
+ dispatch({
480
+ type: "notice",
481
+ level: "warn",
482
+ text: `invalid API key format for ${next}`,
483
+ });
484
+ return;
485
+ }
486
+ await setProviderSecret(next, trimmedKey);
487
+ const saved = await getProviderSecret(next);
488
+ if (saved.value !== trimmedKey) {
489
+ throw new Error(`could not persist the ${next} API key; provider was not changed`);
490
+ }
455
491
  }
456
- await setProviderSecret(next, trimmedKey);
492
+ const nextModel = getProviderModel(next);
493
+ setDefaultProvider(next);
494
+ setProvider(next);
495
+ setModel(nextModel);
496
+ dispatch({
497
+ type: "notice",
498
+ level: "info",
499
+ text: `provider → ${next} · model → ${nextModel}`,
500
+ });
501
+ }
502
+ catch (error) {
503
+ dispatch({
504
+ type: "notice",
505
+ level: "warn",
506
+ text: error instanceof Error ? error.message : String(error),
507
+ });
508
+ }
509
+ finally {
510
+ providerActivationRef.current = undefined;
457
511
  }
458
- const nextModel = getProviderModel(next);
459
- setDefaultProvider(next);
460
- setProvider(next);
461
- setModel(nextModel);
462
- setOverlay({ kind: "none" });
463
- dispatch({
464
- type: "notice",
465
- level: "info",
466
- text: `provider → ${next} · model → ${nextModel}`,
467
- });
468
512
  }, [requestSecret]);
469
513
  const chooseProvider = useCallback(() => {
470
514
  setOverlay({
@@ -593,25 +637,36 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
593
637
  return true;
594
638
  case "/new":
595
639
  cancelCompaction();
596
- void (async () => {
597
- const messages = runner.getMessages();
598
- if (!noHistory &&
599
- !getConfig().privateMode &&
600
- messages.some((m) => m.role === "user"))
601
- // Continuous autosave already persists this conversation under
602
- // its live sessionId — upsert that same row instead of
603
- // minting a fresh, unnamed duplicate.
604
- await upsertSession(runner.getSession().sessionId, messages, undefined, state.items).catch(() => undefined);
605
- runner.reset();
606
- titleRef.current = { titledAtUserCount: 0, inFlight: false };
607
- dispatch({ type: "reset" });
608
- info("fresh session started");
609
- })();
640
+ runner.abort();
641
+ if (autosaveTimer.current) {
642
+ clearTimeout(autosaveTimer.current);
643
+ autosaveTimer.current = undefined;
644
+ }
645
+ const messages = runner.getMessages();
646
+ if (!noHistory &&
647
+ !getConfig().privateMode &&
648
+ messages.some((m) => m.role === "user")) {
649
+ // Persist in the background; the new session must be interactive
650
+ // immediately even if the history backend is slow or locked.
651
+ void upsertSession(runner.getSession().sessionId, messages, undefined, latestItems.current).catch(() => undefined);
652
+ }
653
+ runner.reset();
654
+ titleRef.current = {
655
+ titledAtUserCount: 0,
656
+ inFlight: false,
657
+ requestId: titleRef.current.requestId + 1,
658
+ };
659
+ dispatch({ type: "reset" });
660
+ info("fresh session started");
610
661
  return true;
611
662
  case "/clean":
612
663
  cancelCompaction();
613
664
  runner.reset();
614
- titleRef.current = { titledAtUserCount: 0, inFlight: false };
665
+ titleRef.current = {
666
+ titledAtUserCount: 0,
667
+ inFlight: false,
668
+ requestId: titleRef.current.requestId + 1,
669
+ };
615
670
  dispatch({ type: "reset" });
616
671
  info("fresh session started");
617
672
  return true;
@@ -919,6 +974,7 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
919
974
  titleRef.current = {
920
975
  titledAtUserCount: session.messages.filter((m) => m.role === "user").length,
921
976
  inFlight: false,
977
+ requestId: titleRef.current.requestId + 1,
922
978
  };
923
979
  setOverlay({ kind: "none" });
924
980
  dispatch({
@@ -1870,15 +1926,14 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
1870
1926
  return;
1871
1927
  }
1872
1928
  if (key.ctrl && ch === "c") {
1873
- if (runner.isRunning()) {
1874
- runner.abort();
1875
- return;
1876
- }
1877
1929
  const now = Date.now();
1878
- if (now - lastCtrlC.current < 1500)
1930
+ if (now - lastCtrlC.current < 1500) {
1879
1931
  exitTui();
1880
- else
1881
- lastCtrlC.current = now;
1932
+ return;
1933
+ }
1934
+ lastCtrlC.current = now;
1935
+ if (runner.isRunning())
1936
+ runner.abort();
1882
1937
  return;
1883
1938
  }
1884
1939
  // Slash menu navigation