moderado 0.4.28 → 0.4.34

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 (77) hide show
  1. package/README.md +88 -68
  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 +161 -59
  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/http_shutdown.d.ts +22 -0
  21. package/dist/http_shutdown.d.ts.map +1 -0
  22. package/dist/http_shutdown.js +36 -0
  23. package/dist/http_shutdown.js.map +1 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +16 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/self_update.d.ts +6 -0
  28. package/dist/self_update.d.ts.map +1 -1
  29. package/dist/self_update.js +17 -1
  30. package/dist/self_update.js.map +1 -1
  31. package/dist/sessions.d.ts.map +1 -1
  32. package/dist/sessions.js +6 -1
  33. package/dist/sessions.js.map +1 -1
  34. package/dist/ui/box.d.ts +6 -0
  35. package/dist/ui/box.d.ts.map +1 -1
  36. package/dist/ui/box.js +33 -1
  37. package/dist/ui/box.js.map +1 -1
  38. package/dist/ui/file_mentions.js +18 -9
  39. package/dist/ui/file_mentions.js.map +1 -1
  40. package/dist/ui/model_selector.d.ts.map +1 -1
  41. package/dist/ui/model_selector.js +6 -4
  42. package/dist/ui/model_selector.js.map +1 -1
  43. package/dist/ui/popup.d.ts.map +1 -1
  44. package/dist/ui/popup.js +10 -3
  45. package/dist/ui/popup.js.map +1 -1
  46. package/dist/ui/provider_connect.d.ts +13 -4
  47. package/dist/ui/provider_connect.d.ts.map +1 -1
  48. package/dist/ui/provider_connect.js +37 -29
  49. package/dist/ui/provider_connect.js.map +1 -1
  50. package/dist/ui/welcome.d.ts +25 -3
  51. package/dist/ui/welcome.d.ts.map +1 -1
  52. package/dist/ui/welcome.js +69 -25
  53. package/dist/ui/welcome.js.map +1 -1
  54. package/dist/vendor/contracts/provider.js +8 -8
  55. package/dist/vendor/contracts/provider.js.map +1 -1
  56. package/dist/vendor/core/agent.d.ts.map +1 -1
  57. package/dist/vendor/core/agent.js +6 -3
  58. package/dist/vendor/core/agent.js.map +1 -1
  59. package/dist/vendor/providers/model_discovery.js +1 -1
  60. package/dist/vendor/providers/model_discovery.js.map +1 -1
  61. package/dist/vendor/providers/nvidia/nvidia_adapter.d.ts +1 -1
  62. package/dist/vendor/providers/nvidia/nvidia_adapter.d.ts.map +1 -1
  63. package/dist/vendor/providers/nvidia/nvidia_adapter.js +4 -4
  64. package/dist/vendor/providers/nvidia/nvidia_adapter.js.map +1 -1
  65. package/dist/vendor/providers/nvidia/sse_parser.d.ts +1 -0
  66. package/dist/vendor/providers/nvidia/sse_parser.d.ts.map +1 -1
  67. package/dist/vendor/providers/nvidia/sse_parser.js +2 -1
  68. package/dist/vendor/providers/nvidia/sse_parser.js.map +1 -1
  69. package/dist/vendor/providers/presets.d.ts +12 -4
  70. package/dist/vendor/providers/presets.d.ts.map +1 -1
  71. package/dist/vendor/providers/presets.js +24 -15
  72. package/dist/vendor/providers/presets.js.map +1 -1
  73. package/package.json +26 -8
  74. package/dist/commands/interactive_menu.d.ts +0 -3
  75. package/dist/commands/interactive_menu.d.ts.map +0 -1
  76. package/dist/commands/interactive_menu.js +0 -98
  77. 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';
@@ -24,9 +24,15 @@ import { buildWorkspaceMap } from '../repo_map.js';
24
24
  import { evaluateTokenBudget, parseBudgetCommand } from '../budget.js';
25
25
  import { discoverSkills, formatSkillContext, formatSkillsList, getEnabledSkillNames, selectEnabledSkills, setSkillEnabled } from '../skills.js';
26
26
  import { getHelpCommandLines } from '../ui/help_content.js';
27
+ import { forceExit } from '../http_shutdown.js';
27
28
  export function createConnectionProvider(connection) {
28
29
  return new NvidiaAdapter({ apiKey: connection.apiKey, baseUrl: connection.baseUrl, providerId: connection.id, providerName: connection.displayName });
29
30
  }
31
+ export function getProviderStatusName(connection) {
32
+ return connection?.id === 'moderado' || connection?.id === 'moderado-cloud'
33
+ ? 'Moderado'
34
+ : connection?.displayName;
35
+ }
30
36
  export function formatSessionExitSummary(session, modelId) {
31
37
  const usage = session.usage;
32
38
  const tokens = usage.available
@@ -173,8 +179,18 @@ export function formatTurnFailureAnswer(status, errorEvent, modelId, otherConnec
173
179
  const cleanDetail = rawDetail
174
180
  .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
181
  .trim();
182
+ if (errorEvent?.code === 'ERR_GATEWAY_UNAUTHORIZED') {
183
+ return `⚠️ **Gateway Authorization (${errorEvent.code}):**\n${cleanDetail}`;
184
+ }
185
+ if (errorEvent?.code?.toUpperCase().includes('RATE_LIMIT')) {
186
+ 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\`.`;
187
+ }
176
188
  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
189
  }
190
+ /** A turn whose provider rejected its credential may recover by re-entering a key once. */
191
+ export function turnFailedAuthentication(status, errorEvent) {
192
+ return status === 'failed' && isAuthenticationFailure(errorEvent);
193
+ }
178
194
  export function resolveTurnAssistantAnswer(result, previousHistoryLength, lastErrorEvent, streamedAnswer = '', currentModel = 'unknown', otherConnections = []) {
179
195
  if (result.status === 'cancelled') {
180
196
  const partial = cleanConversationalFiller(streamedAnswer);
@@ -203,6 +219,8 @@ export function saveFailedTurnPrompt(history, prompt, partialResponse = '') {
203
219
  return [...history, { role: 'user', content: prompt }, { role: 'assistant', content: interruptedResponse }];
204
220
  }
205
221
  const CONTINUE_INTERRUPTED_TASK_PROMPT = 'Continue the interrupted task from the saved conversation. Start from the unfinished work and avoid repeating completed steps.';
222
+ /** Braille spinner frames for the live "still running" status row. */
223
+ const ACTIVITY_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
206
224
  function hasInterruptedTurn(messages) {
207
225
  for (let index = messages.length - 1; index >= 0; index -= 1) {
208
226
  const message = messages[index];
@@ -466,7 +484,8 @@ export function formatDirectWebSearchAnswer(result, modelConnected = false) {
466
484
  .filter((line) => line && line !== '...' && line !== '---' && !SEARCH_BLOCK_METADATA.test(line) && !/^https?:\/\//i.test(line));
467
485
  if (!lines.length)
468
486
  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)`;
487
+ const excerpt = lines.slice(0, 10).join('\n');
488
+ return `${excerpt}\n\n${modelConnected ? 'Live web search excerpt.' : 'Live web search excerpt. Connect a model for a summary.'}`;
470
489
  }
471
490
  /** Environment configuration overrides the persistent search provider preference. */
472
491
  export function resolveWebSearchProvider(config) {
@@ -838,6 +857,7 @@ export async function handleChatSession(args, version, signal) {
838
857
  else {
839
858
  let autoContinueRequested = false;
840
859
  const turn = await promptInteractiveTurn({
860
+ getProviderName: () => getProviderStatusName(activeConnection),
841
861
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(), workspace: canonicalWorkspace, version,
842
862
  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
863
  queuedCommands: commandQueue.items,
@@ -856,7 +876,7 @@ export async function handleChatSession(args, version, signal) {
856
876
  }
857
877
  if (activeConnection.kind === 'openai-compatible') {
858
878
  const modelId = await selectCompatibleModelOverlay({
859
- apiKey: activeConnection.id === 'moderado-cloud' ? undefined : activeConnection.apiKey,
879
+ apiKey: activeConnection.id === 'moderado' ? undefined : activeConnection.apiKey,
860
880
  baseUrl: activeConnection.baseUrl,
861
881
  providerId: activeConnection.id,
862
882
  providerName: activeConnection.displayName,
@@ -877,7 +897,7 @@ export async function handleChatSession(args, version, signal) {
877
897
  }
878
898
  return modelId;
879
899
  }
880
- const selection = await selectModelOverlay({ apiKey: activeConnection.id === 'moderado-cloud' ? undefined : activeConnection.apiKey, currentModel, signal, saveSelectionByDefault: true, drawFrame });
900
+ const selection = await selectModelOverlay({ apiKey: activeConnection.id === 'moderado' ? undefined : activeConnection.apiKey, currentModel, signal, saveSelectionByDefault: true, drawFrame });
881
901
  if (selection.modelId && selection.modelId !== currentModel) {
882
902
  currentModel = selection.modelId;
883
903
  activeConnection = { ...activeConnection, defaultModel: selection.modelId };
@@ -895,7 +915,7 @@ export async function handleChatSession(args, version, signal) {
895
915
  const connection = await connectProviderInteractive({
896
916
  signal,
897
917
  drawFrame,
898
- gatewayUrl: config.connections?.['moderado-cloud']?.baseUrl,
918
+ gatewayUrl: config.connections?.moderado?.baseUrl,
899
919
  activeConnection,
900
920
  savedConnections: config.connections,
901
921
  connectProviders: config.connectProviders,
@@ -1160,7 +1180,7 @@ export async function handleChatSession(args, version, signal) {
1160
1180
  lastTokenUsage = undefined;
1161
1181
  process.stdout.write('\x1b[H\x1b[J');
1162
1182
  process.stdout.write(renderFullWelcomeScreen({
1163
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1183
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1164
1184
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1165
1185
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1166
1186
  chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items,
@@ -1176,7 +1196,7 @@ export async function handleChatSession(args, version, signal) {
1176
1196
  lastTokenUsage = undefined;
1177
1197
  process.stdout.write('\x1b[H\x1b[J');
1178
1198
  process.stdout.write(renderFullWelcomeScreen({
1179
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1199
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1180
1200
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1181
1201
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1182
1202
  chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items,
@@ -1211,7 +1231,7 @@ export async function handleChatSession(args, version, signal) {
1211
1231
  }
1212
1232
  process.stdout.write('\x1b[H\x1b[J');
1213
1233
  process.stdout.write(renderFullWelcomeScreen({
1214
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1234
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1215
1235
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1216
1236
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1217
1237
  chatThoughtTime: lastThoughtTime,
@@ -1250,7 +1270,7 @@ export async function handleChatSession(args, version, signal) {
1250
1270
  lastTokenUsage = undefined;
1251
1271
  process.stdout.write('\x1b[H\x1b[J');
1252
1272
  process.stdout.write(renderFullWelcomeScreen({
1253
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1273
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1254
1274
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1255
1275
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1256
1276
  chatThoughtTime: lastThoughtTime,
@@ -1289,12 +1309,12 @@ export async function handleChatSession(args, version, signal) {
1289
1309
  lastOutputTokenRate = undefined;
1290
1310
  lastTokenUsage = undefined;
1291
1311
  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));
1312
+ 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
1313
  process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1294
1314
  continue;
1295
1315
  }
1296
1316
  if (trimmed === '/exit' || trimmed === '/quit') {
1297
- exitCleanly(exitMessage());
1317
+ return await exitCleanly(exitMessage());
1298
1318
  }
1299
1319
  if (trimmed === '/help') {
1300
1320
  lastQuestion = trimmed;
@@ -1304,7 +1324,7 @@ export async function handleChatSession(args, version, signal) {
1304
1324
  lastTokenUsage = undefined;
1305
1325
  process.stdout.write('\x1b[H\x1b[J');
1306
1326
  process.stdout.write(renderFullWelcomeScreen({
1307
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1327
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1308
1328
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1309
1329
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1310
1330
  chatThoughtTime: lastThoughtTime,
@@ -1336,7 +1356,7 @@ export async function handleChatSession(args, version, signal) {
1336
1356
  sessionStore.save(activeSession);
1337
1357
  process.stdout.write('\x1b[H\x1b[J');
1338
1358
  process.stdout.write(renderFullWelcomeScreen({
1339
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1359
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1340
1360
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1341
1361
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1342
1362
  chatThoughtTime: lastThoughtTime,
@@ -1356,7 +1376,7 @@ export async function handleChatSession(args, version, signal) {
1356
1376
  sessionStore.save(activeSession);
1357
1377
  process.stdout.write('\x1b[H\x1b[J');
1358
1378
  process.stdout.write(renderFullWelcomeScreen({
1359
- model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1379
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1360
1380
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1361
1381
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1362
1382
  chatThoughtTime: lastThoughtTime,
@@ -1381,7 +1401,7 @@ export async function handleChatSession(args, version, signal) {
1381
1401
  lastTokenUsage = undefined;
1382
1402
  process.stdout.write('\x1b[H\x1b[J');
1383
1403
  process.stdout.write(renderFullWelcomeScreen({
1384
- model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1404
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1385
1405
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1386
1406
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1387
1407
  }, process.stdout.rows));
@@ -1402,7 +1422,7 @@ export async function handleChatSession(args, version, signal) {
1402
1422
  sessionStore.save(activeSession);
1403
1423
  process.stdout.write('\x1b[H\x1b[J');
1404
1424
  process.stdout.write(renderFullWelcomeScreen({
1405
- model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1425
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1406
1426
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1407
1427
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1408
1428
  chatThoughtTime: lastThoughtTime,
@@ -1418,13 +1438,13 @@ export async function handleChatSession(args, version, signal) {
1418
1438
  drawFrame: (popupLines) => {
1419
1439
  process.stdout.write('\x1b[H\x1b[J');
1420
1440
  process.stdout.write(renderWelcomePopupLayer({
1421
- model: currentModel ?? 'No model connected — use /connect',
1441
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect',
1422
1442
  tokens: Math.round(sessionTokens), cost: costLabel(), workspace: canonicalWorkspace,
1423
1443
  mode: activeMode, autoApprove: activeAutoApprove,
1424
1444
  chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1425
1445
  }, popupLines, process.stdout.columns, process.stdout.rows));
1426
1446
  },
1427
- gatewayUrl: config.connections?.['moderado-cloud']?.baseUrl,
1447
+ gatewayUrl: config.connections?.moderado?.baseUrl,
1428
1448
  activeConnection,
1429
1449
  savedConnections: config.connections,
1430
1450
  connectProviders: config.connectProviders,
@@ -1472,7 +1492,9 @@ export async function handleChatSession(args, version, signal) {
1472
1492
  lastAnswer = '';
1473
1493
  lastTurnDone = false;
1474
1494
  let taskStep;
1475
- let firstAssistantDeltaAt;
1495
+ let activityLabel;
1496
+ let activityFrame = 0;
1497
+ let turnActive = true;
1476
1498
  let outputTokenRate;
1477
1499
  let budgetWarningIssued = false;
1478
1500
  let budgetStopped = false;
@@ -1480,12 +1502,32 @@ export async function handleChatSession(args, version, signal) {
1480
1502
  const usedModels = new Set();
1481
1503
  let answerPosition = { row: 15, column: getChatAnswerArea(process.stdout.columns).startColumn };
1482
1504
  let lastErrorEvent;
1505
+ // Keep a live spinner on the status row for the whole turn: streaming
1506
+ // text and silent tool execution both leave the screen looking settled.
1507
+ const stopActivity = () => {
1508
+ if (thinkingTimer) {
1509
+ clearInterval(thinkingTimer);
1510
+ thinkingTimer = undefined;
1511
+ }
1512
+ };
1513
+ const paintActivity = (glyph, label) => {
1514
+ process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000, taskStep, process.stdout.columns, { glyph, label }));
1515
+ };
1516
+ const startActivity = () => {
1517
+ stopActivity();
1518
+ if (!turnActive)
1519
+ return;
1520
+ activityLabel = undefined;
1521
+ thinkingTimer = setInterval(() => {
1522
+ paintActivity(ACTIVITY_SPINNER_FRAMES[activityFrame++ % ACTIVITY_SPINNER_FRAMES.length], activityLabel ?? 'Working');
1523
+ }, 200);
1524
+ };
1483
1525
  const redrawChatFrame = () => {
1484
1526
  const thoughtTime = Math.max(0.001, (Date.now() - startedAt) / 1000);
1485
1527
  const displayAnswer = streamedAnswer || lastAnswer;
1486
1528
  process.stdout.write('\x1b[H\x1b[J');
1487
1529
  process.stdout.write(renderFullWelcomeScreen({
1488
- model: currentModel ?? 'No model connected — use /connect',
1530
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect',
1489
1531
  tokens: Math.round(sessionTokens + (usageRecorded ? 0 : turnUsage?.usage.totalTokens ?? 0)),
1490
1532
  cost: costLabel(),
1491
1533
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
@@ -1507,8 +1549,24 @@ export async function handleChatSession(args, version, signal) {
1507
1549
  const area = getChatAnswerArea(process.stdout.columns);
1508
1550
  answerPosition = renderChatAnswerDelta(displayAnswer, { row: 15, column: area.startColumn }, area.wrapWidth, area.startColumn);
1509
1551
  }
1552
+ // The frame paints a static status; keep the live row authoritative while the turn runs.
1553
+ if (thinkingTimer)
1554
+ paintActivity(ACTIVITY_SPINNER_FRAMES[activityFrame++ % ACTIVITY_SPINNER_FRAMES.length], activityLabel ?? 'Working');
1510
1555
  };
1556
+ let generationEscapes = 0;
1511
1557
  const onGenerationKeypress = (str, key) => {
1558
+ // Raw mode turns Ctrl+C into a keypress, not SIGINT, so the session-level
1559
+ // handler never sees it. The first press aborts the turn; a second press
1560
+ // means the abort did not take (a stalled stream or a hung tool), so
1561
+ // leave instead of freezing with no way out.
1562
+ const isCtrlC = (key?.ctrl && key.name === 'c') || str === '\x03';
1563
+ if (isCtrlC) {
1564
+ generationEscapes += 1;
1565
+ if (generationEscapes > 1) {
1566
+ void forceExit(130);
1567
+ return;
1568
+ }
1569
+ }
1512
1570
  handleGenerationKeypress(str, key, commandQueue, {
1513
1571
  abort: () => {
1514
1572
  commandQueue.clear();
@@ -1522,9 +1580,18 @@ export async function handleChatSession(args, version, signal) {
1522
1580
  },
1523
1581
  onBtw: async (command) => {
1524
1582
  const resume = suspendGenerationInput?.();
1583
+ stopActivity();
1525
1584
  const ac = new AbortController();
1585
+ let btwEscapes = 0;
1526
1586
  const onBtwKey = (s, k) => {
1527
- if ((k && k.name === 'escape') || s === '\x1b') {
1587
+ const isCtrlC = (k && k.ctrl && k.name === 'c') || s === '\x03';
1588
+ if ((k && k.name === 'escape') || s === '\x1b' || isCtrlC) {
1589
+ // Escape/Ctrl+C stops the /btw query; a second Ctrl+C means the
1590
+ // query ignores the abort, so leave instead of freezing.
1591
+ if (isCtrlC && ++btwEscapes > 1) {
1592
+ void forceExit(130);
1593
+ return;
1594
+ }
1528
1595
  ac.abort();
1529
1596
  }
1530
1597
  };
@@ -1533,7 +1600,7 @@ export async function handleChatSession(args, version, signal) {
1533
1600
  await executeBtwQuery(command, async (popupLines, frameOpts) => {
1534
1601
  process.stdout.write('\x1b[H\x1b[J');
1535
1602
  process.stdout.write(renderWelcomePopupLayer({
1536
- model: currentModel ?? 'No model connected',
1603
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected',
1537
1604
  tokens: Math.round(sessionTokens),
1538
1605
  cost: costLabel(),
1539
1606
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
@@ -1550,7 +1617,8 @@ export async function handleChatSession(args, version, signal) {
1550
1617
  await new Promise((dismissResolve) => {
1551
1618
  const onDismiss = (s, k) => {
1552
1619
  if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
1553
- s === 'q' || s === 'Q' || s === '\x1b') {
1620
+ s === 'q' || s === 'Q' || s === '\x1b' ||
1621
+ (k && k.ctrl && k.name === 'c')) {
1554
1622
  process.stdin.removeListener('keypress', onDismiss);
1555
1623
  dismissResolve();
1556
1624
  }
@@ -1562,6 +1630,7 @@ export async function handleChatSession(args, version, signal) {
1562
1630
  }
1563
1631
  finally {
1564
1632
  process.stdin.removeListener('keypress', onBtwKey);
1633
+ startActivity();
1565
1634
  redrawChatFrame();
1566
1635
  resume?.();
1567
1636
  }
@@ -1569,6 +1638,8 @@ export async function handleChatSession(args, version, signal) {
1569
1638
  });
1570
1639
  };
1571
1640
  if (process.stdin.isTTY) {
1641
+ const onGenerationResize = () => redrawChatFrame();
1642
+ process.stdout.on('resize', onGenerationResize);
1572
1643
  const attachGenerationListener = () => {
1573
1644
  readline.emitKeypressEvents(process.stdin);
1574
1645
  process.stdin.resume();
@@ -1588,16 +1659,13 @@ export async function handleChatSession(args, version, signal) {
1588
1659
  };
1589
1660
  removeGenerationListener = () => {
1590
1661
  process.stdin.removeListener('keypress', onGenerationKeypress);
1662
+ process.stdout.removeListener('resize', onGenerationResize);
1591
1663
  suspendGenerationInput = undefined;
1592
1664
  };
1593
1665
  }
1594
1666
  // Move to the chat layout before the provider can emit its first event.
1595
1667
  redrawChatFrame();
1596
- thinkingTimer = setInterval(() => {
1597
- if (!firstAssistantDeltaAt && !turnUsage) {
1598
- process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000, taskStep, process.stdout.columns));
1599
- }
1600
- }, 400);
1668
+ startActivity();
1601
1669
  const evidenceTask = liveSearch ? buildSearchAnswerTask(effectivePrompt, liveSearch) : undefined;
1602
1670
  const runAgent = () => loop.run(isReview ? createReviewTask(reviewFiles) : evidenceTask ?? createAgentTask(effectivePrompt, activeMode), {
1603
1671
  workspaceRoot: canonicalWorkspace, provider: provider, tools: isReview ? createReviewToolRegistry() : tools, approvalHandler, router, policy,
@@ -1609,9 +1677,21 @@ export async function handleChatSession(args, version, signal) {
1609
1677
  usedModels.add(event.newModelId);
1610
1678
  if (event.type === 'progress') {
1611
1679
  taskStep = { step: event.step, maxSteps: event.maxSteps ?? 0 };
1612
- if (!firstAssistantDeltaAt)
1613
- process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000, taskStep, process.stdout.columns));
1680
+ activityLabel = event.status;
1681
+ paintActivity(ACTIVITY_SPINNER_FRAMES[activityFrame++ % ACTIVITY_SPINNER_FRAMES.length], activityLabel ?? 'Working');
1682
+ }
1683
+ if (event.type === 'tool_call_initiated') {
1684
+ activityLabel = `Running ${event.toolName}`;
1685
+ paintActivity(ACTIVITY_SPINNER_FRAMES[activityFrame++ % ACTIVITY_SPINNER_FRAMES.length], activityLabel);
1686
+ }
1687
+ if (event.type === 'tool_result') {
1688
+ activityLabel = undefined;
1689
+ if (!thinkingTimer)
1690
+ startActivity();
1614
1691
  }
1692
+ // The approval prompt owns the screen until the tool result arrives.
1693
+ if (event.type === 'approval_request')
1694
+ stopActivity();
1615
1695
  if (event.type === 'gateway_status')
1616
1696
  process.stdout.write(renderGatewayFallbackUpdate(event, process.stdout.columns || 80));
1617
1697
  if (event.type === 'usage') {
@@ -1632,11 +1712,6 @@ export async function handleChatSession(args, version, signal) {
1632
1712
  }
1633
1713
  }
1634
1714
  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
1715
  streamedAnswer += event.delta;
1641
1716
  if (Date.now() - lastSessionCheckpointAt >= 1_000)
1642
1717
  checkpointInterruptedTurn();
@@ -1661,28 +1736,24 @@ export async function handleChatSession(args, version, signal) {
1661
1736
  checkpoints.recordPostWrite(canonicalWorkspace, paths);
1662
1737
  },
1663
1738
  });
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
1739
+ /** Prompt for a replacement credential once, persist it, and activate it. Falsy when the user cancels. */
1740
+ const recoverAuthenticationFailure = async () => {
1741
+ if (!activeConnection)
1742
+ return undefined;
1743
+ stopActivity();
1744
+ const connection = activeConnection;
1745
+ const moderadoOAuth = isModeradoOAuthConnection(connection);
1746
+ const moderadoWithoutKey = connection.id === 'moderado' && !connection.apiKey;
1747
+ process.stdout.write(moderadoWithoutKey
1677
1748
  ? '\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 });
1749
+ : moderadoOAuth
1750
+ ? '\nModerado rejected this browser login. Sign in again to retry once.\n'
1751
+ : `\nThe saved ${connection.displayName} API key was rejected. Enter a replacement key to retry once.\n`);
1752
+ const replacement = moderadoOAuth || moderadoWithoutKey
1753
+ ? await loginModeradoInteractive({ signal, gatewayUrl: connection.baseUrl })
1754
+ : await replaceProviderKeyInteractive(connection, { signal });
1684
1755
  if (!replacement)
1685
- throw error;
1756
+ return undefined;
1686
1757
  recordTurnUsage();
1687
1758
  turnUsage = undefined;
1688
1759
  usageRecorded = false;
@@ -1690,8 +1761,37 @@ export async function handleChatSession(args, version, signal) {
1690
1761
  saveConnection(secured);
1691
1762
  config = loadConfig();
1692
1763
  activateConnection(secured);
1764
+ startActivity();
1765
+ return secured;
1766
+ };
1767
+ let result;
1768
+ try {
1769
+ checkpointInterruptedTurn();
1693
1770
  result = await runAgent();
1771
+ // The agent loop reports provider failures as error events and resolves
1772
+ // with a failed result instead of throwing, so key re-entry must be
1773
+ // offered here too. Retry once; a second failure falls through to the
1774
+ // formatted error answer.
1775
+ if (turnFailedAuthentication(result.status, lastErrorEvent) && activeConnection && await recoverAuthenticationFailure()) {
1776
+ lastErrorEvent = undefined;
1777
+ result = await runAgent();
1778
+ }
1694
1779
  }
1780
+ catch (error) {
1781
+ if (!activeConnection || !isAuthenticationFailure(error))
1782
+ throw error;
1783
+ if (!(await recoverAuthenticationFailure()))
1784
+ throw error;
1785
+ result = await runAgent();
1786
+ }
1787
+ // The turn is over: replace the spinner with the settled state while
1788
+ // usage accounting and catalog refresh finish.
1789
+ turnActive = false;
1790
+ stopActivity();
1791
+ process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000, undefined, process.stdout.columns, {
1792
+ glyph: result.status === 'completed' ? '✔' : '✖',
1793
+ label: result.status === 'completed' ? 'Done' : result.status === 'cancelled' ? 'Stopped' : 'Failed',
1794
+ }));
1695
1795
  if (turnUsage) {
1696
1796
  let pricing;
1697
1797
  const modelIds = new Set([...usedModels, result.selectedModel.id]);
@@ -1763,14 +1863,16 @@ export async function handleChatSession(args, version, signal) {
1763
1863
  lastQuestion = trimmed;
1764
1864
  lastThoughtTime = Math.max(0.001, (Date.now() - startedAt) / 1000);
1765
1865
  lastOutputTokenRate = outputTokenRate;
1766
- if (thinkingTimer)
1767
- clearInterval(thinkingTimer);
1866
+ turnActive = false;
1867
+ stopActivity();
1768
1868
  removeGenerationListener?.();
1769
1869
  redrawChatFrame();
1770
1870
  }
1771
1871
  catch (err) {
1772
- if (thinkingTimer)
1872
+ if (thinkingTimer) {
1773
1873
  clearInterval(thinkingTimer);
1874
+ thinkingTimer = undefined;
1875
+ }
1774
1876
  removeGenerationListener?.();
1775
1877
  recordTurnUsage();
1776
1878
  if (turnUsage)
@@ -1796,7 +1898,7 @@ export async function handleChatSession(args, version, signal) {
1796
1898
  lastOutputTokenRate = undefined;
1797
1899
  process.stdout.write('\x1b[H\x1b[J');
1798
1900
  process.stdout.write(renderFullWelcomeScreen({
1799
- model: currentModel ?? 'No model connected — use /connect',
1901
+ provider: getProviderStatusName(activeConnection), model: currentModel ?? 'No model connected — use /connect',
1800
1902
  tokens: Math.round(sessionTokens),
1801
1903
  cost: costLabel(),
1802
1904
  usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,