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

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