@thegitai/cli 1.0.0-preview.2 → 1.0.0-preview.20

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 (38) hide show
  1. package/README.md +32 -4
  2. package/dist/bin/ai.js +57 -291
  3. package/dist/src/agent-mode.js +1 -1
  4. package/dist/src/api/auth.js +2 -2
  5. package/dist/src/api/browser-login.js +72 -3
  6. package/dist/src/api/chat.js +232 -33
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/http.js +16 -3
  9. package/dist/src/api/models.js +9 -4
  10. package/dist/src/executor.js +1 -1
  11. package/dist/src/help-text.js +51 -11
  12. package/dist/src/permissions.js +243 -0
  13. package/dist/src/project-index.js +13 -1
  14. package/dist/src/session-store.js +57 -20
  15. package/dist/src/session.js +14 -3
  16. package/dist/src/tool-executor.js +2 -2
  17. package/dist/src/tools/delete-file.js +14 -0
  18. package/dist/src/tools/patch-file.js +12 -16
  19. package/dist/src/tools/replace-document-text.js +28 -18
  20. package/dist/src/tools/run-command.js +13 -27
  21. package/dist/src/tools/run-node-script.js +11 -26
  22. package/dist/src/tools/str-replace.js +12 -16
  23. package/dist/src/tools/write-file.js +66 -0
  24. package/dist/src/turn-failure-marker.js +11 -0
  25. package/dist/src/ui/prompt-history-store.js +1 -1
  26. package/dist/src/ui/repl.js +569 -151
  27. package/dist/src/ui/tui/bridge.js +10 -0
  28. package/dist/src/ui/tui/build-frame.js +535 -159
  29. package/dist/src/ui/tui/markdown-render.js +81 -73
  30. package/dist/src/ui/tui/shell-input.js +155 -45
  31. package/dist/src/ui/tui/terminal-theme.js +28 -0
  32. package/dist/src/ui/tui/terminal-title.js +3 -0
  33. package/dist/src/ui/tui/terminal-writes.js +48 -0
  34. package/dist/src/ui/tui/text.js +158 -4
  35. package/dist/src/ui/tui/user-input.js +568 -0
  36. package/dist/src/utils.js +9 -0
  37. package/package.json +18 -6
  38. package/dist/src/markdown-renderer.js +0 -112
@@ -1,6 +1,8 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
- import { buildTuiFrame, formatJobElapsed, formatTodoProgress, renderTranscriptEntryLines, } from './tui/build-frame.js';
2
+ import { bucketActionLabel, } from '../permissions.js';
3
+ import { approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, userInputViewportForFrame, } from './tui/build-frame.js';
3
4
  import { createTerminalTitleController } from './tui/terminal-title.js';
5
+ import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
4
6
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
5
7
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
6
8
  import { chat, models } from '../api/index.js';
@@ -9,10 +11,14 @@ import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, ki
9
11
  import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
10
12
  import { setScratchSession } from '../scratch-dir.js';
11
13
  import { cancelActiveCommand } from '../executor.js';
14
+ import { isTurnFailureMarker } from '../turn-failure-marker.js';
15
+ import { clearCliAuthConfig } from '../api/auth.js';
16
+ import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
17
+ import { createUserInputPromptState, formatUserInputTranscript, } from './tui/user-input.js';
12
18
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
13
19
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
14
- import { clearConversation, } from '../session.js';
15
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
20
+ import { startNewConversation, } from '../session.js';
21
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../session-store.js';
16
22
  import { truncate } from '../utils.js';
17
23
  import { writeClipboardText } from '../core/clipboard.js';
18
24
  import { openUrl } from '../core/open-url.js';
@@ -69,23 +75,6 @@ const WORKING_TOOL_PREVIEW_ITEMS = 4;
69
75
  const THINKING_NOTE_PREVIEW_ROWS = 3;
70
76
  const WORKING_TOOL_PREVIEW_ROWS = 3;
71
77
  const AGENT_MODE_LABEL_WIDTH = 16;
72
- const APPROVAL_OPTIONS = [
73
- {
74
- value: 'y',
75
- label: 'Approve once',
76
- description: 'Run this action only this time',
77
- },
78
- {
79
- value: 'a',
80
- label: 'Approve all remaining actions',
81
- description: 'Turn on auto-approve for the rest of the session',
82
- },
83
- {
84
- value: 'n',
85
- label: 'Deny',
86
- description: 'Reject this action',
87
- },
88
- ];
89
78
  export const SLASH_COMMANDS = [
90
79
  {
91
80
  command: '/help',
@@ -112,8 +101,8 @@ export const SLASH_COMMANDS = [
112
101
  description: 'Background jobs: pick to view output or kill',
113
102
  },
114
103
  {
115
- command: '/clear',
116
- description: 'Clear conversation history',
104
+ command: '/new',
105
+ description: 'start a new conversation; this session remains saved',
117
106
  },
118
107
  {
119
108
  command: '/exit',
@@ -780,13 +769,21 @@ function displayUserTextFromHistoryEntry(entry) {
780
769
  .slice(contentStart, contentEnd === -1 ? text.length : contentEnd)
781
770
  .trim();
782
771
  }
783
- function buildTranscriptFromSessionHistory(history) {
772
+ export function buildTranscriptFromSessionHistory(history) {
784
773
  const entries = [];
785
774
  const pendingCalls = new Map();
775
+ const pendingUserInputRequests = new Map();
786
776
  for (const entry of history) {
787
777
  if (!entry?.parts?.length)
788
778
  continue;
789
779
  for (const part of entry.parts) {
780
+ const userInputRequest = part?.userInputRequest;
781
+ if (userInputRequest && typeof userInputRequest === 'object') {
782
+ const requestId = String(userInputRequest.requestId ?? '').trim();
783
+ if (requestId && Array.isArray(userInputRequest.questions)) {
784
+ pendingUserInputRequests.set(requestId, userInputRequest.questions);
785
+ }
786
+ }
790
787
  const call = part?.functionCall;
791
788
  if (!call || typeof call !== 'object')
792
789
  continue;
@@ -796,6 +793,21 @@ function buildTranscriptFromSessionHistory(history) {
796
793
  pendingCalls.set(callId, call);
797
794
  }
798
795
  for (const part of entry.parts) {
796
+ const userInputResult = part?.userInputResult;
797
+ if (userInputResult && typeof userInputResult === 'object') {
798
+ const requestId = String(userInputResult.requestId ?? '').trim();
799
+ const questions = pendingUserInputRequests.get(requestId);
800
+ const result = userInputResult.result;
801
+ if (questions &&
802
+ (result?.status === 'submitted' || result?.status === 'cancelled')) {
803
+ pendingUserInputRequests.delete(requestId);
804
+ entries.push({
805
+ body: formatUserInputTranscript(questions, result),
806
+ kind: result.status === 'submitted' ? 'user' : 'system',
807
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
808
+ });
809
+ }
810
+ }
799
811
  const functionResponse = part?.functionResponse;
800
812
  if (!functionResponse || typeof functionResponse !== 'object')
801
813
  continue;
@@ -824,8 +836,23 @@ function buildTranscriptFromSessionHistory(history) {
824
836
  }
825
837
  continue;
826
838
  }
839
+ if (entry.role === 'user' && entry.kind === 'userInterjection') {
840
+ const text = String(entry.userInput ?? '').trim();
841
+ if (text) {
842
+ entries.push({ body: text, kind: 'user', title: 'You · sent mid-turn' });
843
+ }
844
+ continue;
845
+ }
827
846
  const text = textFromHistoryEntry(entry);
828
847
  if ((entry.role === 'model' || entry.role === 'assistant') && text) {
848
+ if (isTurnFailureMarker(text)) {
849
+ entries.push({
850
+ body: 'This request did not complete.',
851
+ kind: 'system',
852
+ title: 'Previous turn',
853
+ });
854
+ continue;
855
+ }
829
856
  entries.push({ body: text, kind: 'assistant', title: 'Response' });
830
857
  }
831
858
  }
@@ -951,11 +978,14 @@ function createInitialShellState(session, serverModels, debugUi) {
951
978
  activeTurnInput: '',
952
979
  activeTurnInputPreformatted: false,
953
980
  agentMode: session.agentMode,
954
- approvalCursor: getDefaultApprovalCursor(),
981
+ analyzingImages: 0,
982
+ approvalCursor: 0,
955
983
  approvalPrompt: null,
984
+ approvalScrollOffset: 0,
956
985
  autoYes: session.autoYes,
957
986
  backgroundJobs: [],
958
987
  busy: false,
988
+ busyPausedAt: null,
959
989
  busySince: null,
960
990
  clockNow: Date.now(),
961
991
  commandCursor: 0,
@@ -983,6 +1013,7 @@ function createInitialShellState(session, serverModels, debugUi) {
983
1013
  resumePickerSessions: [],
984
1014
  transcriptScrollOffset: 0,
985
1015
  queuedMessage: null,
1016
+ turnMessages: [],
986
1017
  serverModels: serverModels.models,
987
1018
  sudoPrompt: null,
988
1019
  status: 'Ready',
@@ -993,6 +1024,7 @@ function createInitialShellState(session, serverModels, debugUi) {
993
1024
  tokenUsage: formatClientTokenUsage(null),
994
1025
  transcript: [],
995
1026
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
1027
+ userInputPrompt: null,
996
1028
  pastedChunks: [],
997
1029
  imageAttachments: [],
998
1030
  workingTools: [],
@@ -1060,6 +1092,24 @@ function splitThinkingLines(text) {
1060
1092
  .map((part) => part.trim())
1061
1093
  .filter(Boolean));
1062
1094
  }
1095
+ const LIVE_STATUS_PANEL_MAX_CHARS = 72;
1096
+ const TOOL_PROGRESS_STATUS_PATTERN = /^(?:Tool:\s|Running\s\S+(?:\slocally)?\.\.\.$)/i;
1097
+ export function isToolProgressStatus(status) {
1098
+ return TOOL_PROGRESS_STATUS_PATTERN.test(String(status ?? '').trim());
1099
+ }
1100
+ function holdOrPickFallback(currentTitle) {
1101
+ return THINKING_FALLBACK_PHRASES.includes(currentTitle.trim())
1102
+ ? currentTitle
1103
+ : pickThinkingFallbackPhrase();
1104
+ }
1105
+ function liveStatusPanelTitle(status) {
1106
+ const text = String(status ?? '').trim();
1107
+ if (!text || text.includes('\n'))
1108
+ return '';
1109
+ if (isToolProgressStatus(text))
1110
+ return '';
1111
+ return text.length <= LIVE_STATUS_PANEL_MAX_CHARS ? text : '';
1112
+ }
1063
1113
  function thinkingPanelFromStatus(status) {
1064
1114
  const rawText = thinkingNoteFromStatus(status);
1065
1115
  if (!rawText)
@@ -1083,7 +1133,7 @@ function thinkingPanelFromStatus(status) {
1083
1133
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
1084
1134
  return {
1085
1135
  title,
1086
- notes: splitThinkingLines(bodyLines.join('\n')).slice(-3),
1136
+ notes: splitThinkingLines(bodyLines.join('\n')).slice(0, 3),
1087
1137
  };
1088
1138
  }
1089
1139
  export const EXIT_CTRL_C_CONFIRM_MESSAGE = 'Press Ctrl+C again to quit.';
@@ -1213,32 +1263,47 @@ export function navigatePromptHistory(state, direction) {
1213
1263
  promptHistoryCursor: nextCursor,
1214
1264
  };
1215
1265
  }
1216
- function getDefaultApprovalCursor() {
1217
- const denyIndex = APPROVAL_OPTIONS.findIndex((option) => option.value === 'n');
1218
- return denyIndex === -1 ? 0 : denyIndex;
1219
- }
1220
- export function getNextApprovalCursor(currentIndex, direction) {
1221
- return (currentIndex + direction + APPROVAL_OPTIONS.length) % APPROVAL_OPTIONS.length;
1222
- }
1223
- export function getApprovalChoiceForCursor(cursor) {
1224
- return (APPROVAL_OPTIONS[Math.min(Math.max(cursor, 0), APPROVAL_OPTIONS.length - 1)]
1225
- ?.value ?? 'n');
1226
- }
1227
- export function resolveApprovalChoiceFromInput(input) {
1228
- const normalizedInput = String(input ?? '').trim().toLowerCase();
1229
- if (normalizedInput === 'y')
1230
- return 'y';
1231
- if (normalizedInput === 'a')
1232
- return 'a';
1233
- if (normalizedInput === 'n')
1234
- return 'n';
1235
- return null;
1266
+ export function getDefaultApprovalCursor(optionCount) {
1267
+ return Math.max(0, optionCount - 1);
1268
+ }
1269
+ export function getNextApprovalCursor(currentIndex, direction, optionCount) {
1270
+ if (optionCount <= 0)
1271
+ return 0;
1272
+ return (currentIndex + direction + optionCount) % optionCount;
1273
+ }
1274
+ export function pauseBusyClock(state, nowMs) {
1275
+ if (state.busySince === null || state.busyPausedAt !== null)
1276
+ return state;
1277
+ return { ...state, busyPausedAt: nowMs };
1278
+ }
1279
+ export function resumeBusyClock(state, nowMs) {
1280
+ if (state.busyPausedAt === null)
1281
+ return state;
1282
+ return {
1283
+ ...state,
1284
+ busyPausedAt: null,
1285
+ busySince: state.busySince === null
1286
+ ? null
1287
+ : state.busySince + Math.max(0, nowMs - state.busyPausedAt),
1288
+ };
1289
+ }
1290
+ export function busyElapsedMs(state, nowMs) {
1291
+ if (state.busySince === null)
1292
+ return null;
1293
+ return Math.max(0, (state.busyPausedAt ?? nowMs) - state.busySince);
1294
+ }
1295
+ export function busyElapsedSeconds(state, nowMs) {
1296
+ return Math.floor((busyElapsedMs(state, nowMs) ?? 0) / 1000);
1236
1297
  }
1237
1298
  export function buildModelPickerOptions(currentModelId, serverModels) {
1238
1299
  return serverModels.map((model) => ({
1239
1300
  id: model.id,
1240
1301
  label: model.label,
1241
- meta: model.id === currentModelId ? 'current' : '',
1302
+ publicId: model.id,
1303
+ costRating: model.costRating,
1304
+ current: model.id === currentModelId,
1305
+ disabled: false,
1306
+ note: model.description,
1242
1307
  }));
1243
1308
  }
1244
1309
  function getDefaultModelPickerIndex(currentModelId, serverModels) {
@@ -1266,21 +1331,19 @@ function appendCommandLog(state, text) {
1266
1331
  };
1267
1332
  }
1268
1333
  async function saveSessionBoth({ serverSessionClient, session, }) {
1334
+ if (!sessionHasUserMessage(session))
1335
+ return;
1269
1336
  saveSessionState(session);
1270
1337
  await serverSessionClient.save(session);
1271
1338
  }
1272
- function shouldUseRatatuiShell() {
1273
- if (process.env.THEGITAI_PLAIN === '1')
1274
- return false;
1275
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1276
- }
1277
- export function shouldUseClientRatatuiShell() {
1278
- return shouldUseRatatuiShell();
1279
- }
1280
1339
  export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, projectIndex, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1281
- if (!shouldUseRatatuiShell()) {
1282
- throw new Error('Client TUI requires an interactive terminal.');
1340
+ if (process.stdin.isTTY !== true) {
1341
+ throw new Error('stdin is not a terminal');
1342
+ }
1343
+ if (process.stdout.isTTY !== true) {
1344
+ throw new Error('stdout is not a terminal');
1283
1345
  }
1346
+ let fatalError = null;
1284
1347
  await withTuiMode(async () => {
1285
1348
  setBackgroundJobSession(session.sessionId);
1286
1349
  setScratchSession(session.sessionId);
@@ -1290,11 +1353,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1290
1353
  let currentServerModels = serverModels;
1291
1354
  let sessionAutoYes = session.autoYes;
1292
1355
  let resolveDone = null;
1293
- let resolveApprovalChoice = null;
1356
+ let resolvePermissionDecision = null;
1294
1357
  let resolveSudoPassword = null;
1358
+ let pendingUserInput = null;
1295
1359
  let cleanupSudoPasswordPrompt = null;
1296
1360
  let sudoPasswordBuffer = '';
1297
1361
  const bridge = createRatatuiBridge();
1362
+ captureTerminalWrites();
1298
1363
  const { handleShellKeyEvent } = await import('./tui/shell-input.js');
1299
1364
  let terminalCols = 80;
1300
1365
  let terminalRows = 24;
@@ -1307,6 +1372,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1307
1372
  let latestUsageSummary = null;
1308
1373
  let pendingTurnEntries = [];
1309
1374
  let activeTurnAbort = null;
1375
+ let activeServerTurnId = null;
1376
+ let unacknowledged = new Map();
1377
+ const blockedRows = new WeakMap();
1378
+ let newConversationInFlight = false;
1310
1379
  let todosTouchedThisTurn = false;
1311
1380
  const syncTodosState = () => {
1312
1381
  store.update((current) => ({ ...current, todos: listTodos() }));
@@ -1315,6 +1384,36 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1315
1384
  let exitCtrlCArmed = false;
1316
1385
  let exitCtrlCTimer = null;
1317
1386
  let transientStatusTimer = null;
1387
+ const THINKING_MIN_DWELL_MS = 3_000;
1388
+ let thinkingAppliedAt = 0;
1389
+ let pendingThinking = null;
1390
+ const applyThinking = (next) => {
1391
+ thinkingAppliedAt = Date.now();
1392
+ pendingThinking = null;
1393
+ store.update((current) => ({
1394
+ ...current,
1395
+ thinkingTitle: next.title || holdOrPickFallback(current.thinkingTitle),
1396
+ thinkingNotes: next.notes,
1397
+ }));
1398
+ };
1399
+ const queueThinkingUpdate = (next) => {
1400
+ if (Date.now() - thinkingAppliedAt >= THINKING_MIN_DWELL_MS) {
1401
+ applyThinking(next);
1402
+ return;
1403
+ }
1404
+ pendingThinking = next;
1405
+ };
1406
+ const flushPendingThinking = () => {
1407
+ if (!pendingThinking)
1408
+ return;
1409
+ if (Date.now() - thinkingAppliedAt < THINKING_MIN_DWELL_MS)
1410
+ return;
1411
+ applyThinking(pendingThinking);
1412
+ };
1413
+ const resetThinkingPacer = () => {
1414
+ thinkingAppliedAt = 0;
1415
+ pendingThinking = null;
1416
+ };
1318
1417
  const done = new Promise((resolve) => {
1319
1418
  resolveDone = resolve;
1320
1419
  });
@@ -1339,7 +1438,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1339
1438
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1340
1439
  }, 2500);
1341
1440
  };
1342
- const terminalTitle = createTerminalTitleController();
1441
+ const terminalTitle = createTerminalTitleController({
1442
+ write: (title) => bridge.setTitle(title),
1443
+ });
1343
1444
  const syncTerminalTitle = () => {
1344
1445
  const state = store.getState();
1345
1446
  terminalTitle.sync({
@@ -1352,9 +1453,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1352
1453
  return;
1353
1454
  const state = store.getState();
1354
1455
  syncTerminalTitle();
1355
- const elapsedSeconds = state.busySince
1356
- ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1357
- : 0;
1456
+ const elapsedSeconds = busyElapsedSeconds(state, Date.now());
1358
1457
  bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1359
1458
  };
1360
1459
  const remountTui = async () => {
@@ -1480,15 +1579,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1480
1579
  }
1481
1580
  };
1482
1581
  const dismissPendingApproval = () => {
1483
- if (!resolveApprovalChoice)
1582
+ if (!resolvePermissionDecision)
1484
1583
  return;
1485
- const pendingResolve = resolveApprovalChoice;
1486
- resolveApprovalChoice = null;
1487
- pendingResolve('n');
1584
+ const pendingResolve = resolvePermissionDecision;
1585
+ resolvePermissionDecision = null;
1586
+ pendingResolve({ kind: 'deny' });
1488
1587
  store.update((current) => ({
1489
- ...current,
1490
- approvalCursor: getDefaultApprovalCursor(),
1588
+ ...resumeBusyClock(current, Date.now()),
1589
+ approvalCursor: 0,
1491
1590
  approvalPrompt: null,
1591
+ approvalScrollOffset: 0,
1492
1592
  }));
1493
1593
  };
1494
1594
  const dismissPendingSudoPassword = () => {
@@ -1501,17 +1601,34 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1501
1601
  sudoPasswordBuffer = '';
1502
1602
  pendingResolve(null);
1503
1603
  store.update((current) => ({
1504
- ...current,
1604
+ ...resumeBusyClock(current, Date.now()),
1505
1605
  sudoPrompt: null,
1506
1606
  }));
1507
1607
  };
1608
+ const dismissPendingUserInput = () => {
1609
+ const pending = pendingUserInput;
1610
+ if (!pending)
1611
+ return;
1612
+ pendingUserInput = null;
1613
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1614
+ const error = new Error('Turn cancelled.');
1615
+ error.name = 'AbortError';
1616
+ pending.reject(error);
1617
+ store.update((current) => ({
1618
+ ...resumeBusyClock(current, Date.now()),
1619
+ status: current.userInputPrompt?.returnStatus ?? current.status,
1620
+ userInputPrompt: null,
1621
+ }));
1622
+ };
1508
1623
  const cancelActiveTurn = () => {
1509
- if (!store.getState().busy)
1624
+ if (!store.getState().busy || newConversationInFlight)
1510
1625
  return;
1511
1626
  disarmExitConfirm();
1512
1627
  dismissPendingApproval();
1513
1628
  dismissPendingSudoPassword();
1629
+ dismissPendingUserInput();
1514
1630
  activeTurnGeneration += 1;
1631
+ resetThinkingPacer();
1515
1632
  activeTurnAbort?.abort();
1516
1633
  activeTurnAbort = null;
1517
1634
  cancelActiveCommand();
@@ -1529,6 +1646,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1529
1646
  activeTurnInput: '',
1530
1647
  activeTurnInputPreformatted: false,
1531
1648
  busy: false,
1649
+ busyPausedAt: null,
1532
1650
  busySince: null,
1533
1651
  commandLog: [],
1534
1652
  cursor: queued ? queued.body.length : current.cursor,
@@ -1541,8 +1659,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1541
1659
  status: 'Ready',
1542
1660
  thinkingTitle: '',
1543
1661
  thinkingNotes: [],
1662
+ turnMessages: [],
1544
1663
  workingTools: [],
1545
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
1664
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
1546
1665
  }));
1547
1666
  appendStaticEntries(cancelledEntries);
1548
1667
  lastTurnStartedAt = null;
@@ -1550,14 +1669,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1550
1669
  scheduleLiveFrameRemount();
1551
1670
  void remountTui();
1552
1671
  };
1553
- const saveActiveSession = async () => {
1554
- try {
1555
- await saveSessionBoth({ serverSessionClient, session });
1556
- }
1557
- catch (error) {
1558
- appendError(`Session save failed: ${error.message}`);
1559
- }
1560
- };
1561
1672
  const syncShellStateFromSession = () => {
1562
1673
  store.update((current) => ({
1563
1674
  ...current,
@@ -1636,6 +1747,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1636
1747
  }
1637
1748
  dismissPendingApproval();
1638
1749
  dismissPendingSudoPassword();
1750
+ dismissPendingUserInput();
1639
1751
  exiting = true;
1640
1752
  killAllBackgroundJobs();
1641
1753
  store.update((current) => ({
@@ -1645,9 +1757,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1645
1757
  status: 'Exiting...',
1646
1758
  }));
1647
1759
  void bridge.close().then(() => {
1760
+ releaseTerminalWrites();
1648
1761
  resolveDone?.();
1649
1762
  });
1650
1763
  };
1764
+ const exitForAuthenticationError = (error) => {
1765
+ if (!isAuthenticationError(error))
1766
+ return false;
1767
+ clearCliAuthConfig(session.env);
1768
+ if (sessionHasUserMessage(session)) {
1769
+ saveSessionState(session);
1770
+ }
1771
+ fatalError = new Error(authenticationErrorMessage(error));
1772
+ requestExit();
1773
+ return true;
1774
+ };
1775
+ const saveActiveSession = async () => {
1776
+ try {
1777
+ await saveSessionBoth({ serverSessionClient, session });
1778
+ return true;
1779
+ }
1780
+ catch (error) {
1781
+ if (exitForAuthenticationError(error))
1782
+ return false;
1783
+ appendError(`Session save failed: ${error.message}`);
1784
+ return true;
1785
+ }
1786
+ };
1787
+ const saveActiveSessionOrAbort = async () => {
1788
+ try {
1789
+ await saveSessionBoth({ serverSessionClient, session });
1790
+ return true;
1791
+ }
1792
+ catch (error) {
1793
+ if (exitForAuthenticationError(error))
1794
+ return false;
1795
+ appendError(`Session save failed: ${error.message}`);
1796
+ return false;
1797
+ }
1798
+ };
1651
1799
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1652
1800
  if (exiting || signal?.aborted) {
1653
1801
  resolve(null);
@@ -1665,7 +1813,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1665
1813
  cleanupSudoPasswordPrompt = null;
1666
1814
  }
1667
1815
  store.update((current) => ({
1668
- ...current,
1816
+ ...pauseBusyClock(current, Date.now()),
1669
1817
  sudoPrompt: {
1670
1818
  command,
1671
1819
  passwordLength: 0,
@@ -1685,7 +1833,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1685
1833
  cleanupSudoPasswordPrompt = null;
1686
1834
  sudoPasswordBuffer = '';
1687
1835
  store.update((current) => ({
1688
- ...current,
1836
+ ...resumeBusyClock(current, Date.now()),
1689
1837
  sudoPrompt: null,
1690
1838
  status: current.sudoPrompt?.returnStatus ?? current.status,
1691
1839
  }));
@@ -1699,7 +1847,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1699
1847
  cleanupSudoPasswordPrompt = null;
1700
1848
  sudoPasswordBuffer = '';
1701
1849
  store.update((current) => ({
1702
- ...current,
1850
+ ...resumeBusyClock(current, Date.now()),
1703
1851
  sudoPrompt: null,
1704
1852
  status: current.sudoPrompt?.returnStatus ?? current.status,
1705
1853
  }));
@@ -1719,38 +1867,88 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1719
1867
  : null,
1720
1868
  }));
1721
1869
  };
1722
- const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1870
+ const openUserInputPrompt = (questions, signal) => new Promise((resolve, reject) => {
1871
+ if (exiting || signal?.aborted) {
1872
+ const error = new Error('Turn cancelled.');
1873
+ error.name = 'AbortError';
1874
+ reject(error);
1875
+ return;
1876
+ }
1877
+ const onAbort = () => dismissPendingUserInput();
1878
+ pendingUserInput = {
1879
+ questions,
1880
+ resolve,
1881
+ reject,
1882
+ signal,
1883
+ onAbort,
1884
+ };
1885
+ signal?.addEventListener('abort', onAbort, { once: true });
1886
+ store.update((current) => ({
1887
+ ...pauseBusyClock(current, Date.now()),
1888
+ status: 'Waiting for your input',
1889
+ userInputPrompt: createUserInputPromptState(questions, current.status),
1890
+ }));
1891
+ scheduleLiveFrameRemount();
1892
+ });
1893
+ const handleInlineUserInput = async (result) => {
1894
+ const pending = pendingUserInput;
1895
+ const prompt = store.getState().userInputPrompt;
1896
+ if (!pending || !prompt)
1897
+ return;
1898
+ pendingUserInput = null;
1899
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1900
+ store.update((current) => ({
1901
+ ...resumeBusyClock(current, Date.now()),
1902
+ status: prompt.returnStatus,
1903
+ userInputPrompt: null,
1904
+ }));
1905
+ appendTurnAwareEntry({
1906
+ body: formatUserInputTranscript(pending.questions, result),
1907
+ kind: result.status === 'submitted' ? 'user' : 'system',
1908
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
1909
+ });
1910
+ pending.resolve(result);
1911
+ scheduleLiveFrameRemount();
1912
+ };
1913
+ const openApprovalPrompt = (request) => new Promise((resolve) => {
1914
+ const deny = { kind: 'deny' };
1723
1915
  if (exiting) {
1724
- resolve('n');
1916
+ resolve(deny);
1725
1917
  return;
1726
1918
  }
1727
- resolveApprovalChoice = resolve;
1919
+ resolvePermissionDecision = resolve;
1728
1920
  store.update((current) => ({
1729
- ...current,
1730
- approvalCursor: getDefaultApprovalCursor(),
1921
+ ...pauseBusyClock(current, Date.now()),
1922
+ approvalCursor: getDefaultApprovalCursor(request.options.length),
1923
+ approvalOpenedAt: Date.now(),
1924
+ approvalScrollOffset: 0,
1731
1925
  approvalPrompt: {
1732
- title,
1733
- body,
1734
- diffPreview: options.diff && options.filePath
1735
- ? parseDiffPreview(options.diff)
1926
+ title: request.title,
1927
+ body: request.body,
1928
+ diffPreview: request.diff && request.filePath
1929
+ ? parseDiffPreview(request.diff)
1736
1930
  : undefined,
1737
- filePath: options.filePath,
1931
+ filePath: request.filePath,
1932
+ options: request.options,
1738
1933
  returnStatus: current.status,
1739
1934
  },
1740
- status: title,
1935
+ status: request.title,
1741
1936
  }));
1742
1937
  });
1743
- const handleInlineApprovalChoice = async (choice) => {
1938
+ const handleInlineApprovalChoice = async (index) => {
1744
1939
  const current = store.getState();
1745
- const pendingResolve = resolveApprovalChoice;
1746
- resolveApprovalChoice = null;
1940
+ const options = current.approvalPrompt?.options ?? [];
1941
+ const chosen = options[index]?.decision ?? { kind: 'deny' };
1942
+ const pendingResolve = resolvePermissionDecision;
1943
+ resolvePermissionDecision = null;
1747
1944
  store.update((next) => ({
1748
- ...next,
1749
- approvalCursor: getDefaultApprovalCursor(),
1945
+ ...resumeBusyClock(next, Date.now()),
1946
+ approvalCursor: getDefaultApprovalCursor(options.length),
1750
1947
  approvalPrompt: null,
1948
+ approvalScrollOffset: 0,
1751
1949
  status: current.approvalPrompt?.returnStatus ?? next.status,
1752
1950
  }));
1753
- pendingResolve?.(choice);
1951
+ pendingResolve?.(chosen);
1754
1952
  };
1755
1953
  const refreshServerModels = async () => {
1756
1954
  currentServerModels = await models.fetchServerModels({ config: authConfig });
@@ -1784,7 +1982,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1784
1982
  selectedModelId: selected,
1785
1983
  serverModels: fresh,
1786
1984
  });
1787
- await saveActiveSession();
1985
+ if (!(await saveActiveSession()))
1986
+ return;
1788
1987
  syncShellStateFromSession();
1789
1988
  appendStaticEntry({
1790
1989
  body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
@@ -1903,6 +2102,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1903
2102
  await switchToModel(selected.id);
1904
2103
  }
1905
2104
  catch (error) {
2105
+ if (exitForAuthenticationError(error))
2106
+ return;
1906
2107
  appendError(error.message);
1907
2108
  }
1908
2109
  };
@@ -1952,6 +2153,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1952
2153
  store.update((next) => ({
1953
2154
  ...next,
1954
2155
  busy: true,
2156
+ busyPausedAt: null,
1955
2157
  busySince: resumeStartedAt,
1956
2158
  clockNow: resumeStartedAt,
1957
2159
  status: 'Loading session...',
@@ -1964,11 +2166,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1964
2166
  syncBackgroundJobsState();
1965
2167
  setTodoSession(session.sessionId);
1966
2168
  syncTodosState();
1967
- await saveActiveSession();
1968
2169
  syncShellStateFromSession();
1969
2170
  store.update((next) => ({
1970
2171
  ...next,
1971
2172
  busy: false,
2173
+ busyPausedAt: null,
1972
2174
  busySince: null,
1973
2175
  resumePickerFilter: '',
1974
2176
  resumePickerIndex: 0,
@@ -1987,9 +2189,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1987
2189
  await remountTui();
1988
2190
  }
1989
2191
  catch (error) {
2192
+ if (exitForAuthenticationError(error))
2193
+ return;
1990
2194
  store.update((next) => ({
1991
2195
  ...next,
1992
2196
  busy: false,
2197
+ busyPausedAt: null,
1993
2198
  busySince: null,
1994
2199
  resumePickerFilter: '',
1995
2200
  resumePickerIndex: 0,
@@ -2013,6 +2218,129 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2013
2218
  }));
2014
2219
  await handleSubmit(queued.body);
2015
2220
  };
2221
+ let turnMessageCounter = 0;
2222
+ const upsertTurnMessage = (id, patch) => {
2223
+ store.update((current) => ({
2224
+ ...current,
2225
+ turnMessages: current.turnMessages.map((message) => message.id === id ? { ...message, ...patch } : message),
2226
+ }));
2227
+ };
2228
+ const recoverUnacknowledgedMessages = (autoSubmits = true) => {
2229
+ if (unacknowledged.size === 0)
2230
+ return;
2231
+ const stranded = [...unacknowledged.values()];
2232
+ unacknowledged = new Map();
2233
+ for (const { row } of stranded) {
2234
+ upsertTurnMessage(row, {
2235
+ state: 'queued',
2236
+ note: autoSubmits
2237
+ ? 'not read — sending as the next prompt'
2238
+ : 'not read — left in the composer',
2239
+ });
2240
+ }
2241
+ const existing = store.getState().queuedMessage;
2242
+ const combined = [
2243
+ ...stranded.map((entry) => entry.queued),
2244
+ ...(existing ? [existing] : []),
2245
+ ];
2246
+ const body = combined
2247
+ .map((entry) => entry.body)
2248
+ .filter((entry) => entry.trim())
2249
+ .join('\n\n');
2250
+ if (!body.trim())
2251
+ return;
2252
+ scheduleLiveFrameRemount();
2253
+ store.update((current) => ({
2254
+ ...current,
2255
+ queuedMessage: {
2256
+ body,
2257
+ imageAttachments: combined.flatMap((entry) => entry.imageAttachments),
2258
+ pastedChunks: combined.flatMap((entry) => entry.pastedChunks),
2259
+ },
2260
+ }));
2261
+ };
2262
+ const fireQueuedMessage = async () => {
2263
+ const state = store.getState();
2264
+ const queued = state.queuedMessage;
2265
+ if (!queued || !state.busy || exiting)
2266
+ return;
2267
+ const text = (queued.pastedChunks.length
2268
+ ? expandPastedChunks(queued.body, queued.pastedChunks)
2269
+ : queued.body).trim();
2270
+ if (!text)
2271
+ return;
2272
+ const turnId = activeServerTurnId;
2273
+ const blockedReason = queued.imageAttachments.length > 0
2274
+ ? 'waiting — images go with the next prompt'
2275
+ : !turnId
2276
+ ? 'waiting for the turn to end'
2277
+ : null;
2278
+ const existingRow = blockedRows.get(queued);
2279
+ if (existingRow !== undefined) {
2280
+ upsertTurnMessage(existingRow, {
2281
+ state: 'queued',
2282
+ ...(blockedReason ? { note: blockedReason } : {}),
2283
+ });
2284
+ if (blockedReason || !turnId)
2285
+ return;
2286
+ }
2287
+ const id = existingRow ?? ++turnMessageCounter;
2288
+ const messageId = `m${id}_${Date.now().toString(36)}`;
2289
+ if (existingRow === undefined) {
2290
+ scheduleLiveFrameRemount();
2291
+ store.update((current) => ({
2292
+ ...current,
2293
+ turnMessages: [
2294
+ ...current.turnMessages,
2295
+ {
2296
+ id,
2297
+ text,
2298
+ state: blockedReason ? 'queued' : 'sending',
2299
+ ...(blockedReason ? { note: blockedReason } : {}),
2300
+ },
2301
+ ],
2302
+ }));
2303
+ }
2304
+ if (blockedReason || !turnId) {
2305
+ blockedRows.set(queued, id);
2306
+ return;
2307
+ }
2308
+ blockedRows.delete(queued);
2309
+ unacknowledged.set(messageId, { row: id, queued });
2310
+ scheduleLiveFrameRemount();
2311
+ store.update((current) => current.queuedMessage === queued
2312
+ ? { ...current, queuedMessage: null }
2313
+ : current);
2314
+ queueTurnEntry({
2315
+ body: text,
2316
+ kind: 'user',
2317
+ preformatted: queued.pastedChunks.length > 0,
2318
+ title: 'You · sent mid-turn',
2319
+ });
2320
+ const rollback = (note) => {
2321
+ if (!unacknowledged.delete(messageId))
2322
+ return;
2323
+ upsertTurnMessage(id, { state: 'queued', note });
2324
+ scheduleLiveFrameRemount();
2325
+ store.update((current) => current.queuedMessage ? current : { ...current, queuedMessage: queued });
2326
+ };
2327
+ let outcome;
2328
+ try {
2329
+ outcome = await chat.postInterjection({
2330
+ config: authConfig,
2331
+ turnId,
2332
+ text,
2333
+ messageId,
2334
+ });
2335
+ }
2336
+ catch (error) {
2337
+ rollback(`not sent — ${error?.message ?? 'request failed'}`);
2338
+ return;
2339
+ }
2340
+ if (outcome === 'stale') {
2341
+ rollback('turn ended — sending as the next prompt');
2342
+ }
2343
+ };
2016
2344
  const handleSubmit = async (rawInput) => {
2017
2345
  const chunks = store.getState().pastedChunks;
2018
2346
  const expanded = chunks.length
@@ -2139,14 +2467,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2139
2467
  });
2140
2468
  }
2141
2469
  catch (error) {
2470
+ if (exitForAuthenticationError(error))
2471
+ return;
2142
2472
  appendError(error.message);
2143
2473
  }
2144
2474
  finally {
2145
- store.update((current) => ({
2146
- ...current,
2147
- busy: false,
2148
- status: 'Ready',
2149
- }));
2475
+ if (!exiting) {
2476
+ store.update((current) => ({
2477
+ ...current,
2478
+ busy: false,
2479
+ status: 'Ready',
2480
+ }));
2481
+ }
2150
2482
  }
2151
2483
  return;
2152
2484
  }
@@ -2160,14 +2492,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2160
2492
  await openResumePicker();
2161
2493
  }
2162
2494
  catch (error) {
2495
+ if (exitForAuthenticationError(error))
2496
+ return;
2163
2497
  appendError(error.message);
2164
2498
  }
2165
2499
  finally {
2166
- store.update((current) => ({
2167
- ...current,
2168
- busy: false,
2169
- status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2170
- }));
2500
+ if (!exiting) {
2501
+ store.update((current) => ({
2502
+ ...current,
2503
+ busy: false,
2504
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2505
+ }));
2506
+ }
2171
2507
  }
2172
2508
  return;
2173
2509
  }
@@ -2187,43 +2523,84 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2187
2523
  }
2188
2524
  }
2189
2525
  catch (error) {
2526
+ if (exitForAuthenticationError(error))
2527
+ return;
2190
2528
  appendError(error.message);
2191
2529
  }
2192
2530
  finally {
2531
+ if (!exiting) {
2532
+ store.update((current) => ({
2533
+ ...current,
2534
+ busy: false,
2535
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2536
+ }));
2537
+ }
2538
+ }
2539
+ return;
2540
+ }
2541
+ if (input === '/new') {
2542
+ newConversationInFlight = true;
2543
+ store.update((current) => ({
2544
+ ...current,
2545
+ busy: true,
2546
+ busyPausedAt: null,
2547
+ busySince: Date.now(),
2548
+ imageAttachments: [],
2549
+ queuedMessage: null,
2550
+ status: 'Starting a new conversation...',
2551
+ }));
2552
+ if (!(await saveActiveSessionOrAbort())) {
2553
+ newConversationInFlight = false;
2193
2554
  store.update((current) => ({
2194
2555
  ...current,
2195
2556
  busy: false,
2196
- status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2557
+ busyPausedAt: null,
2558
+ busySince: null,
2559
+ status: 'Ready',
2197
2560
  }));
2561
+ await flushQueuedMessage();
2562
+ return;
2198
2563
  }
2199
- return;
2200
- }
2201
- if (input === '/clear') {
2202
- clearConversation(session);
2564
+ startNewConversation(session);
2565
+ setBackgroundJobSession(session.sessionId);
2566
+ setScratchSession(session.sessionId);
2567
+ setTodoSession(session.sessionId);
2203
2568
  clearTodos();
2569
+ syncBackgroundJobsState();
2204
2570
  syncTodosState();
2205
2571
  latestUsageSummary = null;
2206
- await saveActiveSession();
2207
2572
  store.replaceTranscript([
2208
2573
  {
2209
- body: 'Conversation cleared.',
2574
+ body: 'Started a new conversation. The previous session remains saved.',
2210
2575
  kind: 'system',
2211
- title: 'System',
2576
+ title: 'Session',
2212
2577
  },
2213
2578
  ]);
2579
+ await saveActiveSession();
2214
2580
  syncShellStateFromSession();
2215
- store.update((current) => ({ ...current, queuedMessage: null }));
2581
+ newConversationInFlight = false;
2582
+ store.update((current) => ({
2583
+ ...current,
2584
+ busy: false,
2585
+ busyPausedAt: null,
2586
+ busySince: null,
2587
+ status: 'Ready',
2588
+ }));
2216
2589
  await remountTui();
2590
+ await flushQueuedMessage();
2217
2591
  return;
2218
2592
  }
2219
2593
  latestUsageSummary = null;
2220
2594
  disarmExitConfirm();
2595
+ unacknowledged = new Map();
2221
2596
  const turnStartedAt = Date.now();
2222
2597
  const turnGeneration = ++activeTurnGeneration;
2223
2598
  const turnAbort = new AbortController();
2224
2599
  activeTurnAbort = turnAbort;
2225
2600
  lastTurnStartedAt = turnStartedAt;
2226
2601
  todosTouchedThisTurn = false;
2602
+ clearTodos();
2603
+ syncTodosState();
2227
2604
  const userEntry = {
2228
2605
  body: input,
2229
2606
  kind: 'user',
@@ -2232,11 +2609,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2232
2609
  };
2233
2610
  pendingTurnEntries = [];
2234
2611
  queueTurnEntry(userEntry);
2612
+ resetThinkingPacer();
2235
2613
  store.update((current) => ({
2236
2614
  ...current,
2237
2615
  activeTurnInput: input,
2238
2616
  activeTurnInputPreformatted: preformatted,
2617
+ analyzingImages: 0,
2239
2618
  busy: true,
2619
+ busyPausedAt: null,
2240
2620
  busySince: turnStartedAt,
2241
2621
  clockNow: turnStartedAt,
2242
2622
  status: 'Running turn...',
@@ -2245,6 +2625,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2245
2625
  transcriptScrollOffset: 0,
2246
2626
  tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
2247
2627
  turnCounter: current.turnCounter + 1,
2628
+ turnMessages: [],
2248
2629
  workingTools: [],
2249
2630
  }));
2250
2631
  try {
@@ -2255,12 +2636,32 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2255
2636
  input,
2256
2637
  imageAttachments,
2257
2638
  signal: turnAbort.signal,
2639
+ onTurnStart: (serverTurnId) => {
2640
+ if (turnGeneration !== activeTurnGeneration)
2641
+ return;
2642
+ activeServerTurnId = serverTurnId;
2643
+ },
2644
+ onInterjectionDelivered: (event) => {
2645
+ if (turnGeneration !== activeTurnGeneration)
2646
+ return;
2647
+ for (const messageId of event.messageIds ?? []) {
2648
+ const pending = unacknowledged.get(messageId);
2649
+ if (!pending)
2650
+ continue;
2651
+ unacknowledged.delete(messageId);
2652
+ upsertTurnMessage(pending.row, {
2653
+ state: 'delivered',
2654
+ note: undefined,
2655
+ });
2656
+ }
2657
+ },
2258
2658
  });
2259
2659
  if (turnGeneration !== activeTurnGeneration)
2260
2660
  return;
2261
2661
  latestUsageSummary = result.usageSummary ?? null;
2262
2662
  const turnEntries = takePendingTurnEntries();
2263
- await saveActiveSession();
2663
+ if (!(await saveActiveSession()))
2664
+ return;
2264
2665
  syncShellStateFromSession();
2265
2666
  store.update((current) => ({
2266
2667
  ...current,
@@ -2297,10 +2698,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2297
2698
  store.update((current) => ({
2298
2699
  ...current,
2299
2700
  busy: false,
2701
+ busyPausedAt: null,
2300
2702
  busySince: null,
2301
2703
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2302
2704
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2303
2705
  }));
2706
+ recoverUnacknowledgedMessages();
2304
2707
  await flushQueuedMessage();
2305
2708
  }
2306
2709
  catch (error) {
@@ -2311,11 +2714,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2311
2714
  return;
2312
2715
  }
2313
2716
  const cancelled = isTurnCancelledError(error);
2717
+ if (exitForAuthenticationError(error))
2718
+ return;
2314
2719
  store.update((current) => ({
2315
2720
  ...current,
2316
2721
  activeTurnInput: '',
2317
2722
  activeTurnInputPreformatted: false,
2318
2723
  busy: false,
2724
+ busyPausedAt: null,
2319
2725
  busySince: null,
2320
2726
  commandLog: [],
2321
2727
  imageAttachments: [],
@@ -2335,13 +2741,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2335
2741
  title: 'System',
2336
2742
  },
2337
2743
  ]);
2338
- await saveActiveSession();
2744
+ if (!(await saveActiveSession()))
2745
+ return;
2339
2746
  }
2340
2747
  else {
2341
2748
  appendStaticEntries(turnEntries);
2342
2749
  appendError(error.message);
2343
2750
  }
2344
2751
  await remountTui();
2752
+ recoverUnacknowledgedMessages(!cancelled);
2345
2753
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2346
2754
  await flushQueuedMessage();
2347
2755
  }
@@ -2352,24 +2760,34 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2352
2760
  }
2353
2761
  if (turnGeneration === activeTurnGeneration) {
2354
2762
  lastTurnStartedAt = null;
2763
+ activeServerTurnId = null;
2355
2764
  }
2765
+ recoverUnacknowledgedMessages();
2356
2766
  }
2357
2767
  };
2768
+ session.onImageAnalysis = (activeImageCount) => {
2769
+ store.update((current) => ({
2770
+ ...current,
2771
+ analyzingImages: Math.max(0, activeImageCount),
2772
+ }));
2773
+ };
2358
2774
  session.onStatus = (message) => {
2359
2775
  const panel = thinkingPanelFromStatus(message);
2360
2776
  store.update((current) => ({
2361
2777
  ...current,
2362
2778
  status: message,
2363
- thinkingTitle: panel?.title ?? current.thinkingTitle,
2364
- thinkingNotes: panel?.notes ?? current.thinkingNotes,
2365
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2779
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2366
2780
  }));
2781
+ queueThinkingUpdate({
2782
+ title: panel ? panel.title : liveStatusPanelTitle(message),
2783
+ notes: panel ? panel.notes : [],
2784
+ });
2367
2785
  };
2368
2786
  session.onContextLog = (message) => {
2369
2787
  store.update((current) => ({
2370
2788
  ...current,
2371
2789
  contextStatus: message,
2372
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2790
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2373
2791
  }));
2374
2792
  };
2375
2793
  session.onToolEvent = (event) => {
@@ -2438,51 +2856,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2438
2856
  projectIndex.onStatus = session.onStatus;
2439
2857
  projectIndex.onContextLog = session.onContextLog;
2440
2858
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2441
- session.confirmCommand = async (command) => {
2859
+ session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
2860
+ session.requestPermission = async (request) => {
2861
+ const deny = { kind: 'deny' };
2442
2862
  if (exiting)
2443
- return false;
2863
+ return deny;
2444
2864
  if (sessionAutoYes)
2445
- return true;
2446
- const choice = await openApprovalPrompt('Approve command?', command);
2447
- if (choice === 'a') {
2448
- setAgentMode('auto-accept');
2449
- syncShellStateFromSession();
2865
+ return { kind: 'once' };
2866
+ const decision = await openApprovalPrompt(request);
2867
+ if (decision.kind === 'always-bucket') {
2450
2868
  appendTurnAwareEntry({
2451
- body: 'Auto-approve enabled for the rest of this session.',
2869
+ body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
2452
2870
  kind: 'system',
2453
2871
  title: 'Approvals',
2454
2872
  });
2455
- return true;
2456
2873
  }
2457
- return choice === 'y';
2458
- };
2459
- session.confirmPatch = async (filePath, patch) => {
2460
- if (exiting)
2461
- return false;
2462
- if (sessionAutoYes)
2463
- return true;
2464
- const choice = await openApprovalPrompt('Approve patch?', 'Review changes before applying.', {
2465
- diff: patch,
2466
- filePath,
2467
- });
2468
- if (choice === 'a') {
2469
- setAgentMode('auto-accept');
2470
- syncShellStateFromSession();
2874
+ else if (decision.kind === 'always-prefix') {
2471
2875
  appendTurnAwareEntry({
2472
- body: 'Auto-approve enabled for the rest of this session.',
2876
+ body: `Allowed for the rest of this session: commands starting with \`${decision.prefix}\`.`,
2473
2877
  kind: 'system',
2474
2878
  title: 'Approvals',
2475
2879
  });
2476
- return true;
2477
2880
  }
2478
- return choice === 'y';
2881
+ return decision;
2479
2882
  };
2480
2883
  const shellInputHandlers = {
2884
+ getApprovalScrollLimit: () => {
2885
+ const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2886
+ return approvalScrollLimit(store.getState().approvalPrompt, contentWidth, terminalRows);
2887
+ },
2481
2888
  getTranscriptScrollLimit: () => {
2482
2889
  const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2483
2890
  const blocks = store.getState().transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
2484
2891
  return blocks.reduce((total, block, index) => total + block.length + (index > 0 ? 1 : 0), 0);
2485
2892
  },
2893
+ getUserInputViewport: () => {
2894
+ const nowMs = Date.now();
2895
+ const state = store.getState();
2896
+ return userInputViewportForFrame(state, terminalCols, terminalRows, spinnerFrame, busyElapsedSeconds(state, nowMs), nowMs);
2897
+ },
2486
2898
  onCancelTurn: cancelActiveTurn,
2487
2899
  onCycleAgentMode: cycleAgentMode,
2488
2900
  onCtrlC: handleCtrlC,
@@ -2491,6 +2903,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2491
2903
  onLiveFrameShapeChange: scheduleLiveFrameRemount,
2492
2904
  onRequestExit: requestExit,
2493
2905
  onResolveApproval: handleInlineApprovalChoice,
2906
+ onResolveUserInput: handleInlineUserInput,
2494
2907
  onResumeSession: handleInlineResumeSelection,
2495
2908
  onSelectionCopy: handleAppSelectionCopy,
2496
2909
  onSelectModel: handleInlineModelSelection,
@@ -2498,6 +2911,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2498
2911
  onJobsPickerKill: handleJobsPickerKill,
2499
2912
  onSudoPasswordInput: handleSudoPasswordInput,
2500
2913
  onSubmit: handleSubmit,
2914
+ onFireQueuedMessage: fireQueuedMessage,
2501
2915
  };
2502
2916
  const unsubscribe = store.subscribe(() => {
2503
2917
  renderCurrentFrame();
@@ -2505,6 +2919,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2505
2919
  const spinnerTimer = setInterval(() => {
2506
2920
  spinnerFrame = (spinnerFrame + 1) % 6;
2507
2921
  if (store.getState().busy) {
2922
+ flushPendingThinking();
2508
2923
  renderCurrentFrame();
2509
2924
  }
2510
2925
  }, 120);
@@ -2563,7 +2978,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2563
2978
  setTodoSession(null);
2564
2979
  setBackgroundJobUpdateHook(null);
2565
2980
  await bridge.close();
2981
+ releaseTerminalWrites();
2566
2982
  setCommandOutputHook(null);
2567
2983
  }
2568
2984
  });
2985
+ if (fatalError)
2986
+ throw fatalError;
2569
2987
  }