moderado 0.4.28 → 0.4.33

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.
Files changed (66) hide show
  1. package/README.md +82 -67
  2. package/dist/args.d.ts.map +1 -1
  3. package/dist/args.js +6 -2
  4. package/dist/args.js.map +1 -1
  5. package/dist/commands/chat.d.ts +6 -0
  6. package/dist/commands/chat.d.ts.map +1 -1
  7. package/dist/commands/chat.js +135 -56
  8. package/dist/commands/chat.js.map +1 -1
  9. package/dist/commands/models.d.ts.map +1 -1
  10. package/dist/commands/models.js +14 -12
  11. package/dist/commands/models.js.map +1 -1
  12. package/dist/commands/run.d.ts +11 -0
  13. package/dist/commands/run.d.ts.map +1 -1
  14. package/dist/commands/run.js +25 -8
  15. package/dist/commands/run.js.map +1 -1
  16. package/dist/config.d.ts +2 -1
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +50 -19
  19. package/dist/config.js.map +1 -1
  20. package/dist/sessions.d.ts.map +1 -1
  21. package/dist/sessions.js +6 -1
  22. package/dist/sessions.js.map +1 -1
  23. package/dist/ui/box.d.ts +6 -0
  24. package/dist/ui/box.d.ts.map +1 -1
  25. package/dist/ui/box.js +33 -1
  26. package/dist/ui/box.js.map +1 -1
  27. package/dist/ui/file_mentions.js +18 -9
  28. package/dist/ui/file_mentions.js.map +1 -1
  29. package/dist/ui/model_selector.d.ts.map +1 -1
  30. package/dist/ui/model_selector.js +6 -4
  31. package/dist/ui/model_selector.js.map +1 -1
  32. package/dist/ui/popup.d.ts.map +1 -1
  33. package/dist/ui/popup.js +4 -3
  34. package/dist/ui/popup.js.map +1 -1
  35. package/dist/ui/provider_connect.d.ts +13 -4
  36. package/dist/ui/provider_connect.d.ts.map +1 -1
  37. package/dist/ui/provider_connect.js +37 -29
  38. package/dist/ui/provider_connect.js.map +1 -1
  39. package/dist/ui/welcome.d.ts +14 -2
  40. package/dist/ui/welcome.d.ts.map +1 -1
  41. package/dist/ui/welcome.js +43 -17
  42. package/dist/ui/welcome.js.map +1 -1
  43. package/dist/vendor/contracts/provider.js +8 -8
  44. package/dist/vendor/contracts/provider.js.map +1 -1
  45. package/dist/vendor/core/agent.d.ts.map +1 -1
  46. package/dist/vendor/core/agent.js +6 -3
  47. package/dist/vendor/core/agent.js.map +1 -1
  48. package/dist/vendor/providers/model_discovery.js +1 -1
  49. package/dist/vendor/providers/model_discovery.js.map +1 -1
  50. package/dist/vendor/providers/nvidia/nvidia_adapter.d.ts +1 -1
  51. package/dist/vendor/providers/nvidia/nvidia_adapter.d.ts.map +1 -1
  52. package/dist/vendor/providers/nvidia/nvidia_adapter.js +4 -4
  53. package/dist/vendor/providers/nvidia/nvidia_adapter.js.map +1 -1
  54. package/dist/vendor/providers/nvidia/sse_parser.d.ts +1 -0
  55. package/dist/vendor/providers/nvidia/sse_parser.d.ts.map +1 -1
  56. package/dist/vendor/providers/nvidia/sse_parser.js +2 -1
  57. package/dist/vendor/providers/nvidia/sse_parser.js.map +1 -1
  58. package/dist/vendor/providers/presets.d.ts +12 -4
  59. package/dist/vendor/providers/presets.d.ts.map +1 -1
  60. package/dist/vendor/providers/presets.js +24 -15
  61. package/dist/vendor/providers/presets.js.map +1 -1
  62. package/package.json +3 -2
  63. package/dist/commands/interactive_menu.d.ts +0 -3
  64. package/dist/commands/interactive_menu.d.ts.map +0 -1
  65. package/dist/commands/interactive_menu.js +0 -98
  66. package/dist/commands/interactive_menu.js.map +0 -1
@@ -10,7 +10,7 @@ import { MemoryCredentialStore } from '../credentials.js';
10
10
  import { WindowsCredentialStore } from '../windows_credentials.js';
11
11
  import { TerminalApprovalHandler } from '../ui/terminal_approval.js';
12
12
  import { selectCompatibleModelOverlay, selectModelOverlay, showModelConnectionRequired } from '../ui/model_selector.js';
13
- import { connectProviderInteractive, isAuthenticationFailure, isModeradoCloudOAuthConnection, loginModeradoCloudInteractive, replaceProviderKeyInteractive } from '../ui/provider_connect.js';
13
+ import { connectProviderInteractive, isAuthenticationFailure, isModeradoOAuthConnection, loginModeradoInteractive, replaceProviderKeyInteractive } from '../ui/provider_connect.js';
14
14
  import { initWorkspace } from './init.js';
15
15
  import { expandMentions, createWorkspaceFileSource } from '../ui/file_mentions.js';
16
16
  import { listFiles } from '../vendor/tools/index.js';
@@ -27,6 +27,11 @@ import { getHelpCommandLines } from '../ui/help_content.js';
27
27
  export function createConnectionProvider(connection) {
28
28
  return new NvidiaAdapter({ apiKey: connection.apiKey, baseUrl: connection.baseUrl, providerId: connection.id, providerName: connection.displayName });
29
29
  }
30
+ export function getProviderStatusName(connection) {
31
+ return connection?.id === 'moderado' || connection?.id === 'moderado-cloud'
32
+ ? 'Moderado'
33
+ : connection?.displayName;
34
+ }
30
35
  export function formatSessionExitSummary(session, modelId) {
31
36
  const usage = session.usage;
32
37
  const tokens = usage.available
@@ -173,8 +178,18 @@ export function formatTurnFailureAnswer(status, errorEvent, modelId, otherConnec
173
178
  const cleanDetail = rawDetail
174
179
  .replace(/^(?:NVIDIA NIM|OpenAI|OpenRouter|[a-zA-Z0-9_-]+)\s*(?:request failed|rate limit exceeded|service or model unavailable)?\s*(?:\([^)]*\))?\s*(?:during\s*streamChat)?:\s*/i, '')
175
180
  .trim();
181
+ if (errorEvent?.code === 'ERR_GATEWAY_UNAUTHORIZED') {
182
+ return `⚠️ **Gateway Authorization (${errorEvent.code}):**\n${cleanDetail}`;
183
+ }
184
+ if (errorEvent?.code?.toUpperCase().includes('RATE_LIMIT')) {
185
+ return `⚠️ **Model Error (${errorEvent.code}):**\n${cleanDetail || 'Provider rate limit reached.'}\n\n👉 **Suggestion:** The provider temporarily limited requests for \`${modelId}\`. Please retry shortly or choose another model with \`/model\`.`;
186
+ }
176
187
  return `⚠️ **Model Error (${errorEvent?.code || 'ERR_INFERENCE_FAILED'}):**\n${cleanDetail || 'Model returned no response.'}\n\n👉 **Suggestion:** The model \`${modelId}\` encountered an error. Switch models with \`/model\` or choose a provider with \`/connect\`${altSuggestion}.`;
177
188
  }
189
+ /** A turn whose provider rejected its credential may recover by re-entering a key once. */
190
+ export function turnFailedAuthentication(status, errorEvent) {
191
+ return status === 'failed' && isAuthenticationFailure(errorEvent);
192
+ }
178
193
  export function resolveTurnAssistantAnswer(result, previousHistoryLength, lastErrorEvent, streamedAnswer = '', currentModel = 'unknown', otherConnections = []) {
179
194
  if (result.status === 'cancelled') {
180
195
  const partial = cleanConversationalFiller(streamedAnswer);
@@ -203,6 +218,8 @@ export function saveFailedTurnPrompt(history, prompt, partialResponse = '') {
203
218
  return [...history, { role: 'user', content: prompt }, { role: 'assistant', content: interruptedResponse }];
204
219
  }
205
220
  const CONTINUE_INTERRUPTED_TASK_PROMPT = 'Continue the interrupted task from the saved conversation. Start from the unfinished work and avoid repeating completed steps.';
221
+ /** Braille spinner frames for the live "still running" status row. */
222
+ const ACTIVITY_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
206
223
  function hasInterruptedTurn(messages) {
207
224
  for (let index = messages.length - 1; index >= 0; index -= 1) {
208
225
  const message = messages[index];
@@ -466,7 +483,8 @@ export function formatDirectWebSearchAnswer(result, modelConnected = false) {
466
483
  .filter((line) => line && line !== '...' && line !== '---' && !SEARCH_BLOCK_METADATA.test(line) && !/^https?:\/\//i.test(line));
467
484
  if (!lines.length)
468
485
  return 'Web search returned no readable content.';
469
- return `${lines.slice(0, 10).join('\n')}\n\n(${modelConnected ? 'model summary unavailable — ' : 'no model connected — '}raw search excerpt)`;
486
+ const excerpt = lines.slice(0, 10).join('\n');
487
+ return `${excerpt}\n\n${modelConnected ? 'Live web search excerpt.' : 'Live web search excerpt. Connect a model for a summary.'}`;
470
488
  }
471
489
  /** Environment configuration overrides the persistent search provider preference. */
472
490
  export function resolveWebSearchProvider(config) {
@@ -838,6 +856,7 @@ export async function handleChatSession(args, version, signal) {
838
856
  else {
839
857
  let autoContinueRequested = false;
840
858
  const turn = await promptInteractiveTurn({
859
+ getProviderName: () => getProviderStatusName(activeConnection),
841
860
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(), workspace: canonicalWorkspace, version,
842
861
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, initialMode: activeMode, initialAutoApprove: activeAutoApprove, isFirstTurn: isFirst, signal, chatQuestion: lastQuestion || undefined, chatAnswer: lastAnswer || undefined, chatThoughtTime: lastThoughtTime, chatTaskDone: lastTurnDone, outputTokenRate: lastOutputTokenRate, tokenUsage: lastTokenUsage,
843
862
  queuedCommands: commandQueue.items,
@@ -856,7 +875,7 @@ export async function handleChatSession(args, version, signal) {
856
875
  }
857
876
  if (activeConnection.kind === 'openai-compatible') {
858
877
  const modelId = await selectCompatibleModelOverlay({
859
- apiKey: activeConnection.id === 'moderado-cloud' ? undefined : activeConnection.apiKey,
878
+ apiKey: activeConnection.id === 'moderado' ? undefined : activeConnection.apiKey,
860
879
  baseUrl: activeConnection.baseUrl,
861
880
  providerId: activeConnection.id,
862
881
  providerName: activeConnection.displayName,
@@ -877,7 +896,7 @@ export async function handleChatSession(args, version, signal) {
877
896
  }
878
897
  return modelId;
879
898
  }
880
- const selection = await selectModelOverlay({ apiKey: activeConnection.id === 'moderado-cloud' ? undefined : activeConnection.apiKey, currentModel, signal, saveSelectionByDefault: true, drawFrame });
899
+ const selection = await selectModelOverlay({ apiKey: activeConnection.id === 'moderado' ? undefined : activeConnection.apiKey, currentModel, signal, saveSelectionByDefault: true, drawFrame });
881
900
  if (selection.modelId && selection.modelId !== currentModel) {
882
901
  currentModel = selection.modelId;
883
902
  activeConnection = { ...activeConnection, defaultModel: selection.modelId };
@@ -895,7 +914,7 @@ export async function handleChatSession(args, version, signal) {
895
914
  const connection = await connectProviderInteractive({
896
915
  signal,
897
916
  drawFrame,
898
- gatewayUrl: config.connections?.['moderado-cloud']?.baseUrl,
917
+ gatewayUrl: config.connections?.moderado?.baseUrl,
899
918
  activeConnection,
900
919
  savedConnections: config.connections,
901
920
  connectProviders: config.connectProviders,
@@ -1160,7 +1179,7 @@ export async function handleChatSession(args, version, signal) {
1160
1179
  lastTokenUsage = undefined;
1161
1180
  process.stdout.write('\x1b[H\x1b[J');
1162
1181
  process.stdout.write(renderFullWelcomeScreen({
1163
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1182
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1164
1183
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1165
1184
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1166
1185
  chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items,
@@ -1176,7 +1195,7 @@ export async function handleChatSession(args, version, signal) {
1176
1195
  lastTokenUsage = undefined;
1177
1196
  process.stdout.write('\x1b[H\x1b[J');
1178
1197
  process.stdout.write(renderFullWelcomeScreen({
1179
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1198
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1180
1199
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1181
1200
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1182
1201
  chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items,
@@ -1211,7 +1230,7 @@ export async function handleChatSession(args, version, signal) {
1211
1230
  }
1212
1231
  process.stdout.write('\x1b[H\x1b[J');
1213
1232
  process.stdout.write(renderFullWelcomeScreen({
1214
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1233
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1215
1234
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1216
1235
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1217
1236
  chatThoughtTime: lastThoughtTime,
@@ -1250,7 +1269,7 @@ export async function handleChatSession(args, version, signal) {
1250
1269
  lastTokenUsage = undefined;
1251
1270
  process.stdout.write('\x1b[H\x1b[J');
1252
1271
  process.stdout.write(renderFullWelcomeScreen({
1253
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1272
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1254
1273
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1255
1274
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1256
1275
  chatThoughtTime: lastThoughtTime,
@@ -1289,7 +1308,7 @@ export async function handleChatSession(args, version, signal) {
1289
1308
  lastOutputTokenRate = undefined;
1290
1309
  lastTokenUsage = undefined;
1291
1310
  process.stdout.write('\x1b[H\x1b[J');
1292
- process.stdout.write(renderFullWelcomeScreen({ model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(), usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode, autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer, chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items }, process.stdout.rows));
1311
+ process.stdout.write(renderFullWelcomeScreen({ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(), usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode, autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer, chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items }, process.stdout.rows));
1293
1312
  process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1294
1313
  continue;
1295
1314
  }
@@ -1304,7 +1323,7 @@ export async function handleChatSession(args, version, signal) {
1304
1323
  lastTokenUsage = undefined;
1305
1324
  process.stdout.write('\x1b[H\x1b[J');
1306
1325
  process.stdout.write(renderFullWelcomeScreen({
1307
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1326
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1308
1327
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1309
1328
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1310
1329
  chatThoughtTime: lastThoughtTime,
@@ -1336,7 +1355,7 @@ export async function handleChatSession(args, version, signal) {
1336
1355
  sessionStore.save(activeSession);
1337
1356
  process.stdout.write('\x1b[H\x1b[J');
1338
1357
  process.stdout.write(renderFullWelcomeScreen({
1339
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1358
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1340
1359
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1341
1360
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1342
1361
  chatThoughtTime: lastThoughtTime,
@@ -1356,7 +1375,7 @@ export async function handleChatSession(args, version, signal) {
1356
1375
  sessionStore.save(activeSession);
1357
1376
  process.stdout.write('\x1b[H\x1b[J');
1358
1377
  process.stdout.write(renderFullWelcomeScreen({
1359
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1378
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1360
1379
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1361
1380
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1362
1381
  chatThoughtTime: lastThoughtTime,
@@ -1381,7 +1400,7 @@ export async function handleChatSession(args, version, signal) {
1381
1400
  lastTokenUsage = undefined;
1382
1401
  process.stdout.write('\x1b[H\x1b[J');
1383
1402
  process.stdout.write(renderFullWelcomeScreen({
1384
- model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1403
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1385
1404
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1386
1405
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1387
1406
  }, process.stdout.rows));
@@ -1402,7 +1421,7 @@ export async function handleChatSession(args, version, signal) {
1402
1421
  sessionStore.save(activeSession);
1403
1422
  process.stdout.write('\x1b[H\x1b[J');
1404
1423
  process.stdout.write(renderFullWelcomeScreen({
1405
- model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1424
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1406
1425
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1407
1426
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1408
1427
  chatThoughtTime: lastThoughtTime,
@@ -1418,13 +1437,13 @@ export async function handleChatSession(args, version, signal) {
1418
1437
  drawFrame: (popupLines) => {
1419
1438
  process.stdout.write('\x1b[H\x1b[J');
1420
1439
  process.stdout.write(renderWelcomePopupLayer({
1421
- model: currentModel ?? 'No model connected — use /connect',
1440
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect',
1422
1441
  tokens: Math.round(sessionTokens), cost: costLabel(), workspace: canonicalWorkspace,
1423
1442
  mode: activeMode, autoApprove: activeAutoApprove,
1424
1443
  chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1425
1444
  }, popupLines, process.stdout.columns, process.stdout.rows));
1426
1445
  },
1427
- gatewayUrl: config.connections?.['moderado-cloud']?.baseUrl,
1446
+ gatewayUrl: config.connections?.moderado?.baseUrl,
1428
1447
  activeConnection,
1429
1448
  savedConnections: config.connections,
1430
1449
  connectProviders: config.connectProviders,
@@ -1472,7 +1491,9 @@ export async function handleChatSession(args, version, signal) {
1472
1491
  lastAnswer = '';
1473
1492
  lastTurnDone = false;
1474
1493
  let taskStep;
1475
- let firstAssistantDeltaAt;
1494
+ let activityLabel;
1495
+ let activityFrame = 0;
1496
+ let turnActive = true;
1476
1497
  let outputTokenRate;
1477
1498
  let budgetWarningIssued = false;
1478
1499
  let budgetStopped = false;
@@ -1480,12 +1501,32 @@ export async function handleChatSession(args, version, signal) {
1480
1501
  const usedModels = new Set();
1481
1502
  let answerPosition = { row: 15, column: getChatAnswerArea(process.stdout.columns).startColumn };
1482
1503
  let lastErrorEvent;
1504
+ // Keep a live spinner on the status row for the whole turn: streaming
1505
+ // text and silent tool execution both leave the screen looking settled.
1506
+ const stopActivity = () => {
1507
+ if (thinkingTimer) {
1508
+ clearInterval(thinkingTimer);
1509
+ thinkingTimer = undefined;
1510
+ }
1511
+ };
1512
+ const paintActivity = (glyph, label) => {
1513
+ process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000, taskStep, process.stdout.columns, { glyph, label }));
1514
+ };
1515
+ const startActivity = () => {
1516
+ stopActivity();
1517
+ if (!turnActive)
1518
+ return;
1519
+ activityLabel = undefined;
1520
+ thinkingTimer = setInterval(() => {
1521
+ paintActivity(ACTIVITY_SPINNER_FRAMES[activityFrame++ % ACTIVITY_SPINNER_FRAMES.length], activityLabel ?? 'Working');
1522
+ }, 200);
1523
+ };
1483
1524
  const redrawChatFrame = () => {
1484
1525
  const thoughtTime = Math.max(0.001, (Date.now() - startedAt) / 1000);
1485
1526
  const displayAnswer = streamedAnswer || lastAnswer;
1486
1527
  process.stdout.write('\x1b[H\x1b[J');
1487
1528
  process.stdout.write(renderFullWelcomeScreen({
1488
- model: currentModel ?? 'No model connected — use /connect',
1529
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect',
1489
1530
  tokens: Math.round(sessionTokens + (usageRecorded ? 0 : turnUsage?.usage.totalTokens ?? 0)),
1490
1531
  cost: costLabel(),
1491
1532
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
@@ -1507,6 +1548,9 @@ export async function handleChatSession(args, version, signal) {
1507
1548
  const area = getChatAnswerArea(process.stdout.columns);
1508
1549
  answerPosition = renderChatAnswerDelta(displayAnswer, { row: 15, column: area.startColumn }, area.wrapWidth, area.startColumn);
1509
1550
  }
1551
+ // The frame paints a static status; keep the live row authoritative while the turn runs.
1552
+ if (thinkingTimer)
1553
+ paintActivity(ACTIVITY_SPINNER_FRAMES[activityFrame++ % ACTIVITY_SPINNER_FRAMES.length], activityLabel ?? 'Working');
1510
1554
  };
1511
1555
  const onGenerationKeypress = (str, key) => {
1512
1556
  handleGenerationKeypress(str, key, commandQueue, {
@@ -1522,6 +1566,7 @@ export async function handleChatSession(args, version, signal) {
1522
1566
  },
1523
1567
  onBtw: async (command) => {
1524
1568
  const resume = suspendGenerationInput?.();
1569
+ stopActivity();
1525
1570
  const ac = new AbortController();
1526
1571
  const onBtwKey = (s, k) => {
1527
1572
  if ((k && k.name === 'escape') || s === '\x1b') {
@@ -1533,7 +1578,7 @@ export async function handleChatSession(args, version, signal) {
1533
1578
  await executeBtwQuery(command, async (popupLines, frameOpts) => {
1534
1579
  process.stdout.write('\x1b[H\x1b[J');
1535
1580
  process.stdout.write(renderWelcomePopupLayer({
1536
- model: currentModel ?? 'No model connected',
1581
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected',
1537
1582
  tokens: Math.round(sessionTokens),
1538
1583
  cost: costLabel(),
1539
1584
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
@@ -1562,6 +1607,7 @@ export async function handleChatSession(args, version, signal) {
1562
1607
  }
1563
1608
  finally {
1564
1609
  process.stdin.removeListener('keypress', onBtwKey);
1610
+ startActivity();
1565
1611
  redrawChatFrame();
1566
1612
  resume?.();
1567
1613
  }
@@ -1569,6 +1615,8 @@ export async function handleChatSession(args, version, signal) {
1569
1615
  });
1570
1616
  };
1571
1617
  if (process.stdin.isTTY) {
1618
+ const onGenerationResize = () => redrawChatFrame();
1619
+ process.stdout.on('resize', onGenerationResize);
1572
1620
  const attachGenerationListener = () => {
1573
1621
  readline.emitKeypressEvents(process.stdin);
1574
1622
  process.stdin.resume();
@@ -1588,16 +1636,13 @@ export async function handleChatSession(args, version, signal) {
1588
1636
  };
1589
1637
  removeGenerationListener = () => {
1590
1638
  process.stdin.removeListener('keypress', onGenerationKeypress);
1639
+ process.stdout.removeListener('resize', onGenerationResize);
1591
1640
  suspendGenerationInput = undefined;
1592
1641
  };
1593
1642
  }
1594
1643
  // Move to the chat layout before the provider can emit its first event.
1595
1644
  redrawChatFrame();
1596
- thinkingTimer = setInterval(() => {
1597
- if (!firstAssistantDeltaAt && !turnUsage) {
1598
- process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000, taskStep, process.stdout.columns));
1599
- }
1600
- }, 400);
1645
+ startActivity();
1601
1646
  const evidenceTask = liveSearch ? buildSearchAnswerTask(effectivePrompt, liveSearch) : undefined;
1602
1647
  const runAgent = () => loop.run(isReview ? createReviewTask(reviewFiles) : evidenceTask ?? createAgentTask(effectivePrompt, activeMode), {
1603
1648
  workspaceRoot: canonicalWorkspace, provider: provider, tools: isReview ? createReviewToolRegistry() : tools, approvalHandler, router, policy,
@@ -1609,9 +1654,21 @@ export async function handleChatSession(args, version, signal) {
1609
1654
  usedModels.add(event.newModelId);
1610
1655
  if (event.type === 'progress') {
1611
1656
  taskStep = { step: event.step, maxSteps: event.maxSteps ?? 0 };
1612
- if (!firstAssistantDeltaAt)
1613
- process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000, taskStep, process.stdout.columns));
1657
+ activityLabel = event.status;
1658
+ paintActivity(ACTIVITY_SPINNER_FRAMES[activityFrame++ % ACTIVITY_SPINNER_FRAMES.length], activityLabel ?? 'Working');
1659
+ }
1660
+ if (event.type === 'tool_call_initiated') {
1661
+ activityLabel = `Running ${event.toolName}`;
1662
+ paintActivity(ACTIVITY_SPINNER_FRAMES[activityFrame++ % ACTIVITY_SPINNER_FRAMES.length], activityLabel);
1663
+ }
1664
+ if (event.type === 'tool_result') {
1665
+ activityLabel = undefined;
1666
+ if (!thinkingTimer)
1667
+ startActivity();
1614
1668
  }
1669
+ // The approval prompt owns the screen until the tool result arrives.
1670
+ if (event.type === 'approval_request')
1671
+ stopActivity();
1615
1672
  if (event.type === 'gateway_status')
1616
1673
  process.stdout.write(renderGatewayFallbackUpdate(event, process.stdout.columns || 80));
1617
1674
  if (event.type === 'usage') {
@@ -1632,11 +1689,6 @@ export async function handleChatSession(args, version, signal) {
1632
1689
  }
1633
1690
  }
1634
1691
  if (event.type === 'assistant_delta') {
1635
- if (!firstAssistantDeltaAt && event.delta.trim().length > 0) {
1636
- firstAssistantDeltaAt = Date.now();
1637
- if (thinkingTimer)
1638
- clearInterval(thinkingTimer);
1639
- }
1640
1692
  streamedAnswer += event.delta;
1641
1693
  if (Date.now() - lastSessionCheckpointAt >= 1_000)
1642
1694
  checkpointInterruptedTurn();
@@ -1661,28 +1713,24 @@ export async function handleChatSession(args, version, signal) {
1661
1713
  checkpoints.recordPostWrite(canonicalWorkspace, paths);
1662
1714
  },
1663
1715
  });
1664
- let result;
1665
- try {
1666
- checkpointInterruptedTurn();
1667
- result = await runAgent();
1668
- }
1669
- catch (error) {
1670
- if (!activeConnection || !isAuthenticationFailure(error))
1671
- throw error;
1672
- if (thinkingTimer)
1673
- clearInterval(thinkingTimer);
1674
- const cloudOAuth = isModeradoCloudOAuthConnection(activeConnection);
1675
- const cloudWithoutKey = activeConnection.id === 'moderado-cloud' && !activeConnection.apiKey;
1676
- process.stdout.write(cloudWithoutKey
1716
+ /** Prompt for a replacement credential once, persist it, and activate it. Falsy when the user cancels. */
1717
+ const recoverAuthenticationFailure = async () => {
1718
+ if (!activeConnection)
1719
+ return undefined;
1720
+ stopActivity();
1721
+ const connection = activeConnection;
1722
+ const moderadoOAuth = isModeradoOAuthConnection(connection);
1723
+ const moderadoWithoutKey = connection.id === 'moderado' && !connection.apiKey;
1724
+ process.stdout.write(moderadoWithoutKey
1677
1725
  ? '\nThis free Gateway route requires a Moderado website API key. Sign in or enter a key to retry once.\n'
1678
- : cloudOAuth
1679
- ? '\nModerado Cloud rejected this browser login. Sign in again to retry once.\n'
1680
- : `\nThe saved ${activeConnection.displayName} API key was rejected. Enter a replacement key to retry once.\n`);
1681
- const replacement = cloudOAuth || cloudWithoutKey
1682
- ? await loginModeradoCloudInteractive({ signal, gatewayUrl: activeConnection.baseUrl })
1683
- : await replaceProviderKeyInteractive(activeConnection, { signal });
1726
+ : moderadoOAuth
1727
+ ? '\nModerado rejected this browser login. Sign in again to retry once.\n'
1728
+ : `\nThe saved ${connection.displayName} API key was rejected. Enter a replacement key to retry once.\n`);
1729
+ const replacement = moderadoOAuth || moderadoWithoutKey
1730
+ ? await loginModeradoInteractive({ signal, gatewayUrl: connection.baseUrl })
1731
+ : await replaceProviderKeyInteractive(connection, { signal });
1684
1732
  if (!replacement)
1685
- throw error;
1733
+ return undefined;
1686
1734
  recordTurnUsage();
1687
1735
  turnUsage = undefined;
1688
1736
  usageRecorded = false;
@@ -1690,8 +1738,37 @@ export async function handleChatSession(args, version, signal) {
1690
1738
  saveConnection(secured);
1691
1739
  config = loadConfig();
1692
1740
  activateConnection(secured);
1741
+ startActivity();
1742
+ return secured;
1743
+ };
1744
+ let result;
1745
+ try {
1746
+ checkpointInterruptedTurn();
1747
+ result = await runAgent();
1748
+ // The agent loop reports provider failures as error events and resolves
1749
+ // with a failed result instead of throwing, so key re-entry must be
1750
+ // offered here too. Retry once; a second failure falls through to the
1751
+ // formatted error answer.
1752
+ if (turnFailedAuthentication(result.status, lastErrorEvent) && activeConnection && await recoverAuthenticationFailure()) {
1753
+ lastErrorEvent = undefined;
1754
+ result = await runAgent();
1755
+ }
1756
+ }
1757
+ catch (error) {
1758
+ if (!activeConnection || !isAuthenticationFailure(error))
1759
+ throw error;
1760
+ if (!(await recoverAuthenticationFailure()))
1761
+ throw error;
1693
1762
  result = await runAgent();
1694
1763
  }
1764
+ // The turn is over: replace the spinner with the settled state while
1765
+ // usage accounting and catalog refresh finish.
1766
+ turnActive = false;
1767
+ stopActivity();
1768
+ process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000, undefined, process.stdout.columns, {
1769
+ glyph: result.status === 'completed' ? '✔' : '✖',
1770
+ label: result.status === 'completed' ? 'Done' : result.status === 'cancelled' ? 'Stopped' : 'Failed',
1771
+ }));
1695
1772
  if (turnUsage) {
1696
1773
  let pricing;
1697
1774
  const modelIds = new Set([...usedModels, result.selectedModel.id]);
@@ -1763,14 +1840,16 @@ export async function handleChatSession(args, version, signal) {
1763
1840
  lastQuestion = trimmed;
1764
1841
  lastThoughtTime = Math.max(0.001, (Date.now() - startedAt) / 1000);
1765
1842
  lastOutputTokenRate = outputTokenRate;
1766
- if (thinkingTimer)
1767
- clearInterval(thinkingTimer);
1843
+ turnActive = false;
1844
+ stopActivity();
1768
1845
  removeGenerationListener?.();
1769
1846
  redrawChatFrame();
1770
1847
  }
1771
1848
  catch (err) {
1772
- if (thinkingTimer)
1849
+ if (thinkingTimer) {
1773
1850
  clearInterval(thinkingTimer);
1851
+ thinkingTimer = undefined;
1852
+ }
1774
1853
  removeGenerationListener?.();
1775
1854
  recordTurnUsage();
1776
1855
  if (turnUsage)
@@ -1796,7 +1875,7 @@ export async function handleChatSession(args, version, signal) {
1796
1875
  lastOutputTokenRate = undefined;
1797
1876
  process.stdout.write('\x1b[H\x1b[J');
1798
1877
  process.stdout.write(renderFullWelcomeScreen({
1799
- model: currentModel ?? 'No model connected — use /connect',
1878
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect',
1800
1879
  tokens: Math.round(sessionTokens),
1801
1880
  cost: costLabel(),
1802
1881
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,