@thegitai/cli 1.0.0-preview.3 → 1.0.0-preview.30

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 (58) hide show
  1. package/README.md +39 -6
  2. package/dist/bin/ai.js +142 -383
  3. package/dist/src/agent-mode.js +1 -6
  4. package/dist/src/api/auth.js +6 -4
  5. package/dist/src/api/browser-login.js +152 -37
  6. package/dist/src/api/chat.js +258 -38
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/default-host.js +1 -0
  9. package/dist/src/api/http.js +69 -7
  10. package/dist/src/api/models.js +19 -10
  11. package/dist/src/background-jobs.js +2 -2
  12. package/dist/src/cli-args.js +19 -5
  13. package/dist/src/core/clipboard.js +7 -13
  14. package/dist/src/core/image-limits.js +56 -0
  15. package/dist/src/core/image-path-extractor.js +70 -3
  16. package/dist/src/core/session-image-store.js +199 -0
  17. package/dist/src/executor.js +25 -3
  18. package/dist/src/help-text.js +63 -16
  19. package/dist/src/permissions.js +243 -0
  20. package/dist/src/session-safety.js +0 -12
  21. package/dist/src/session-store.js +121 -20
  22. package/dist/src/session.js +14 -3
  23. package/dist/src/signin.js +58 -0
  24. package/dist/src/tool-executor.js +11 -46
  25. package/dist/src/tools/delete-file.js +15 -3
  26. package/dist/src/tools/index.js +13 -10
  27. package/dist/src/tools/patch-file.js +12 -26
  28. package/dist/src/tools/read-image-file.js +85 -0
  29. package/dist/src/tools/replace-document-text.js +28 -18
  30. package/dist/src/tools/restore-checkpoint.js +0 -1
  31. package/dist/src/tools/run-command.js +14 -71
  32. package/dist/src/tools/run-node-script.js +12 -81
  33. package/dist/src/tools/save-generated-image.js +120 -0
  34. package/dist/src/tools/str-replace.js +12 -26
  35. package/dist/src/tools/undo-edit.js +1 -6
  36. package/dist/src/tools/write-file.js +67 -11
  37. package/dist/src/turn-failure-marker.js +11 -0
  38. package/dist/src/ui/prompt-history-store.js +1 -1
  39. package/dist/src/ui/repl.js +610 -164
  40. package/dist/src/ui/tui/bridge.js +10 -0
  41. package/dist/src/ui/tui/build-frame.js +452 -115
  42. package/dist/src/ui/tui/markdown-render.js +81 -73
  43. package/dist/src/ui/tui/shell-input.js +206 -63
  44. package/dist/src/ui/tui/terminal-theme.js +28 -0
  45. package/dist/src/ui/tui/terminal-title.js +3 -0
  46. package/dist/src/ui/tui/terminal-writes.js +48 -0
  47. package/dist/src/ui/tui/text.js +158 -4
  48. package/dist/src/ui/tui/user-input.js +568 -0
  49. package/dist/src/utils.js +9 -0
  50. package/package.json +29 -6
  51. package/dist/src/markdown-renderer.js +0 -112
  52. package/dist/src/project-index.js +0 -221
  53. package/dist/src/tools/code-intel.js +0 -472
  54. package/dist/src/tools/find-symbol.js +0 -70
  55. package/dist/src/tools/hover-symbol.js +0 -95
  56. package/dist/src/tools/list-symbols.js +0 -55
  57. package/dist/src/tools/search-code.js +0 -37
  58. package/dist/src/tools/signature-help.js +0 -118
@@ -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, logoutFromServer, } 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,12 @@ 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',
107
+ },
108
+ {
109
+ command: '/logout',
110
+ description: 'Sign out and quit',
117
111
  },
118
112
  {
119
113
  command: '/exit',
@@ -514,6 +508,8 @@ function formatToolResultState(result) {
514
508
  return 'Blocked';
515
509
  if (result?.ok === true)
516
510
  return 'OK';
511
+ if (Number.isInteger(result?.httpStatus))
512
+ return String(result.httpStatus);
517
513
  return 'Failed';
518
514
  }
519
515
  function formatRunCommandResultState(result) {
@@ -670,7 +666,7 @@ function buildFileChangeEntry(event) {
670
666
  title: `Edited ${filePath}${summary}`,
671
667
  };
672
668
  }
673
- function buildWorkingToolEntry(event) {
669
+ export function buildWorkingToolEntry(event) {
674
670
  const { call, result } = event;
675
671
  const error = typeof result?.error === 'string' && result.error.trim()
676
672
  ? `\n${truncate(result.error.trim(), 180)}`
@@ -780,13 +776,21 @@ function displayUserTextFromHistoryEntry(entry) {
780
776
  .slice(contentStart, contentEnd === -1 ? text.length : contentEnd)
781
777
  .trim();
782
778
  }
783
- function buildTranscriptFromSessionHistory(history) {
779
+ export function buildTranscriptFromSessionHistory(history) {
784
780
  const entries = [];
785
781
  const pendingCalls = new Map();
782
+ const pendingUserInputRequests = new Map();
786
783
  for (const entry of history) {
787
784
  if (!entry?.parts?.length)
788
785
  continue;
789
786
  for (const part of entry.parts) {
787
+ const userInputRequest = part?.userInputRequest;
788
+ if (userInputRequest && typeof userInputRequest === 'object') {
789
+ const requestId = String(userInputRequest.requestId ?? '').trim();
790
+ if (requestId && Array.isArray(userInputRequest.questions)) {
791
+ pendingUserInputRequests.set(requestId, userInputRequest.questions);
792
+ }
793
+ }
790
794
  const call = part?.functionCall;
791
795
  if (!call || typeof call !== 'object')
792
796
  continue;
@@ -796,6 +800,21 @@ function buildTranscriptFromSessionHistory(history) {
796
800
  pendingCalls.set(callId, call);
797
801
  }
798
802
  for (const part of entry.parts) {
803
+ const userInputResult = part?.userInputResult;
804
+ if (userInputResult && typeof userInputResult === 'object') {
805
+ const requestId = String(userInputResult.requestId ?? '').trim();
806
+ const questions = pendingUserInputRequests.get(requestId);
807
+ const result = userInputResult.result;
808
+ if (questions &&
809
+ (result?.status === 'submitted' || result?.status === 'cancelled')) {
810
+ pendingUserInputRequests.delete(requestId);
811
+ entries.push({
812
+ body: formatUserInputTranscript(questions, result),
813
+ kind: result.status === 'submitted' ? 'user' : 'system',
814
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
815
+ });
816
+ }
817
+ }
799
818
  const functionResponse = part?.functionResponse;
800
819
  if (!functionResponse || typeof functionResponse !== 'object')
801
820
  continue;
@@ -824,8 +843,23 @@ function buildTranscriptFromSessionHistory(history) {
824
843
  }
825
844
  continue;
826
845
  }
846
+ if (entry.role === 'user' && entry.kind === 'userInterjection') {
847
+ const text = String(entry.userInput ?? '').trim();
848
+ if (text) {
849
+ entries.push({ body: text, kind: 'user', title: 'You · sent mid-turn' });
850
+ }
851
+ continue;
852
+ }
827
853
  const text = textFromHistoryEntry(entry);
828
854
  if ((entry.role === 'model' || entry.role === 'assistant') && text) {
855
+ if (isTurnFailureMarker(text)) {
856
+ entries.push({
857
+ body: 'This request did not complete.',
858
+ kind: 'system',
859
+ title: 'Previous turn',
860
+ });
861
+ continue;
862
+ }
829
863
  entries.push({ body: text, kind: 'assistant', title: 'Response' });
830
864
  }
831
865
  }
@@ -951,11 +985,15 @@ function createInitialShellState(session, serverModels, debugUi) {
951
985
  activeTurnInput: '',
952
986
  activeTurnInputPreformatted: false,
953
987
  agentMode: session.agentMode,
954
- approvalCursor: getDefaultApprovalCursor(),
988
+ analyzingImages: 0,
989
+ generatingImage: false,
990
+ approvalCursor: 0,
955
991
  approvalPrompt: null,
992
+ approvalScrollOffset: 0,
956
993
  autoYes: session.autoYes,
957
994
  backgroundJobs: [],
958
995
  busy: false,
996
+ busyPausedAt: null,
959
997
  busySince: null,
960
998
  clockNow: Date.now(),
961
999
  commandCursor: 0,
@@ -983,6 +1021,7 @@ function createInitialShellState(session, serverModels, debugUi) {
983
1021
  resumePickerSessions: [],
984
1022
  transcriptScrollOffset: 0,
985
1023
  queuedMessage: null,
1024
+ turnMessages: [],
986
1025
  serverModels: serverModels.models,
987
1026
  sudoPrompt: null,
988
1027
  status: 'Ready',
@@ -993,6 +1032,7 @@ function createInitialShellState(session, serverModels, debugUi) {
993
1032
  tokenUsage: formatClientTokenUsage(null),
994
1033
  transcript: [],
995
1034
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
1035
+ userInputPrompt: null,
996
1036
  pastedChunks: [],
997
1037
  imageAttachments: [],
998
1038
  workingTools: [],
@@ -1009,7 +1049,6 @@ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
1009
1049
  const reasoningTokens = usageSummary?.reasoningTokens ?? 0;
1010
1050
  const cacheTokens = usageSummary?.cacheTokens ?? 0;
1011
1051
  const cacheWriteTokens = usageSummary?.cacheWriteTokens ?? 0;
1012
- const indexTokens = usageSummary?.indexTokens ?? 0;
1013
1052
  return [
1014
1053
  'Session tokens',
1015
1054
  `in ${formatTokenCount(inputTokens)}`,
@@ -1017,7 +1056,6 @@ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
1017
1056
  ...(reasoningTokens > 0 ? [`think ${formatTokenCount(reasoningTokens)}`] : []),
1018
1057
  `cache ${formatTokenCount(cacheTokens)}`,
1019
1058
  `write ${formatTokenCount(cacheWriteTokens)}`,
1020
- `index ${formatTokenCount(indexTokens)}`,
1021
1059
  ].join(' • ');
1022
1060
  }
1023
1061
  export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
@@ -1060,6 +1098,24 @@ function splitThinkingLines(text) {
1060
1098
  .map((part) => part.trim())
1061
1099
  .filter(Boolean));
1062
1100
  }
1101
+ const LIVE_STATUS_PANEL_MAX_CHARS = 72;
1102
+ const TOOL_PROGRESS_STATUS_PATTERN = /^(?:Tool:\s|Running\s\S+(?:\slocally)?\.\.\.$)/i;
1103
+ export function isToolProgressStatus(status) {
1104
+ return TOOL_PROGRESS_STATUS_PATTERN.test(String(status ?? '').trim());
1105
+ }
1106
+ function holdOrPickFallback(currentTitle) {
1107
+ return THINKING_FALLBACK_PHRASES.includes(currentTitle.trim())
1108
+ ? currentTitle
1109
+ : pickThinkingFallbackPhrase();
1110
+ }
1111
+ function liveStatusPanelTitle(status) {
1112
+ const text = String(status ?? '').trim();
1113
+ if (!text || text.includes('\n'))
1114
+ return '';
1115
+ if (isToolProgressStatus(text))
1116
+ return '';
1117
+ return text.length <= LIVE_STATUS_PANEL_MAX_CHARS ? text : '';
1118
+ }
1063
1119
  function thinkingPanelFromStatus(status) {
1064
1120
  const rawText = thinkingNoteFromStatus(status);
1065
1121
  if (!rawText)
@@ -1083,7 +1139,7 @@ function thinkingPanelFromStatus(status) {
1083
1139
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
1084
1140
  return {
1085
1141
  title,
1086
- notes: splitThinkingLines(bodyLines.join('\n')).slice(-3),
1142
+ notes: splitThinkingLines(bodyLines.join('\n')).slice(0, 3),
1087
1143
  };
1088
1144
  }
1089
1145
  export const EXIT_CTRL_C_CONFIRM_MESSAGE = 'Press Ctrl+C again to quit.';
@@ -1213,26 +1269,37 @@ export function navigatePromptHistory(state, direction) {
1213
1269
  promptHistoryCursor: nextCursor,
1214
1270
  };
1215
1271
  }
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;
1272
+ export function getDefaultApprovalCursor(optionCount) {
1273
+ return Math.max(0, optionCount - 1);
1274
+ }
1275
+ export function getNextApprovalCursor(currentIndex, direction, optionCount) {
1276
+ if (optionCount <= 0)
1277
+ return 0;
1278
+ return (currentIndex + direction + optionCount) % optionCount;
1279
+ }
1280
+ export function pauseBusyClock(state, nowMs) {
1281
+ if (state.busySince === null || state.busyPausedAt !== null)
1282
+ return state;
1283
+ return { ...state, busyPausedAt: nowMs };
1284
+ }
1285
+ export function resumeBusyClock(state, nowMs) {
1286
+ if (state.busyPausedAt === null)
1287
+ return state;
1288
+ return {
1289
+ ...state,
1290
+ busyPausedAt: null,
1291
+ busySince: state.busySince === null
1292
+ ? null
1293
+ : state.busySince + Math.max(0, nowMs - state.busyPausedAt),
1294
+ };
1295
+ }
1296
+ export function busyElapsedMs(state, nowMs) {
1297
+ if (state.busySince === null)
1298
+ return null;
1299
+ return Math.max(0, (state.busyPausedAt ?? nowMs) - state.busySince);
1300
+ }
1301
+ export function busyElapsedSeconds(state, nowMs) {
1302
+ return Math.floor((busyElapsedMs(state, nowMs) ?? 0) / 1000);
1236
1303
  }
1237
1304
  export function buildModelPickerOptions(currentModelId, serverModels) {
1238
1305
  return serverModels.map((model) => ({
@@ -1270,35 +1337,37 @@ function appendCommandLog(state, text) {
1270
1337
  };
1271
1338
  }
1272
1339
  async function saveSessionBoth({ serverSessionClient, session, }) {
1340
+ if (!sessionHasUserMessage(session))
1341
+ return;
1273
1342
  saveSessionState(session);
1274
1343
  await serverSessionClient.save(session);
1275
1344
  }
1276
- function shouldUseRatatuiShell() {
1277
- if (process.env.THEGITAI_PLAIN === '1')
1278
- return false;
1279
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1280
- }
1281
- export function shouldUseClientRatatuiShell() {
1282
- return shouldUseRatatuiShell();
1283
- }
1284
- export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, projectIndex, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1285
- if (!shouldUseRatatuiShell()) {
1286
- throw new Error('Client TUI requires an interactive terminal.');
1345
+ export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1346
+ if (process.stdin.isTTY !== true) {
1347
+ throw new Error('stdin is not a terminal');
1348
+ }
1349
+ if (process.stdout.isTTY !== true) {
1350
+ throw new Error('stdout is not a terminal');
1287
1351
  }
1352
+ let fatalError = null;
1353
+ let signedOut = false;
1288
1354
  await withTuiMode(async () => {
1289
1355
  setBackgroundJobSession(session.sessionId);
1290
1356
  setScratchSession(session.sessionId);
1357
+ setImageStoreSession(session.sessionId);
1291
1358
  setTodoSession(session.sessionId);
1292
1359
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1293
1360
  store.replaceTranscript(createSessionTranscript(session));
1294
1361
  let currentServerModels = serverModels;
1295
1362
  let sessionAutoYes = session.autoYes;
1296
1363
  let resolveDone = null;
1297
- let resolveApprovalChoice = null;
1364
+ let resolvePermissionDecision = null;
1298
1365
  let resolveSudoPassword = null;
1366
+ let pendingUserInput = null;
1299
1367
  let cleanupSudoPasswordPrompt = null;
1300
1368
  let sudoPasswordBuffer = '';
1301
1369
  const bridge = createRatatuiBridge();
1370
+ captureTerminalWrites();
1302
1371
  const { handleShellKeyEvent } = await import('./tui/shell-input.js');
1303
1372
  let terminalCols = 80;
1304
1373
  let terminalRows = 24;
@@ -1311,6 +1380,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1311
1380
  let latestUsageSummary = null;
1312
1381
  let pendingTurnEntries = [];
1313
1382
  let activeTurnAbort = null;
1383
+ let activeServerTurnId = null;
1384
+ let unacknowledged = new Map();
1385
+ const blockedRows = new WeakMap();
1386
+ let newConversationInFlight = false;
1314
1387
  let todosTouchedThisTurn = false;
1315
1388
  const syncTodosState = () => {
1316
1389
  store.update((current) => ({ ...current, todos: listTodos() }));
@@ -1319,6 +1392,36 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1319
1392
  let exitCtrlCArmed = false;
1320
1393
  let exitCtrlCTimer = null;
1321
1394
  let transientStatusTimer = null;
1395
+ const THINKING_MIN_DWELL_MS = 3_000;
1396
+ let thinkingAppliedAt = 0;
1397
+ let pendingThinking = null;
1398
+ const applyThinking = (next) => {
1399
+ thinkingAppliedAt = Date.now();
1400
+ pendingThinking = null;
1401
+ store.update((current) => ({
1402
+ ...current,
1403
+ thinkingTitle: next.title || holdOrPickFallback(current.thinkingTitle),
1404
+ thinkingNotes: next.notes,
1405
+ }));
1406
+ };
1407
+ const queueThinkingUpdate = (next) => {
1408
+ if (Date.now() - thinkingAppliedAt >= THINKING_MIN_DWELL_MS) {
1409
+ applyThinking(next);
1410
+ return;
1411
+ }
1412
+ pendingThinking = next;
1413
+ };
1414
+ const flushPendingThinking = () => {
1415
+ if (!pendingThinking)
1416
+ return;
1417
+ if (Date.now() - thinkingAppliedAt < THINKING_MIN_DWELL_MS)
1418
+ return;
1419
+ applyThinking(pendingThinking);
1420
+ };
1421
+ const resetThinkingPacer = () => {
1422
+ thinkingAppliedAt = 0;
1423
+ pendingThinking = null;
1424
+ };
1322
1425
  const done = new Promise((resolve) => {
1323
1426
  resolveDone = resolve;
1324
1427
  });
@@ -1343,7 +1446,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1343
1446
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1344
1447
  }, 2500);
1345
1448
  };
1346
- const terminalTitle = createTerminalTitleController();
1449
+ const terminalTitle = createTerminalTitleController({
1450
+ write: (title) => bridge.setTitle(title),
1451
+ });
1347
1452
  const syncTerminalTitle = () => {
1348
1453
  const state = store.getState();
1349
1454
  terminalTitle.sync({
@@ -1356,9 +1461,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1356
1461
  return;
1357
1462
  const state = store.getState();
1358
1463
  syncTerminalTitle();
1359
- const elapsedSeconds = state.busySince
1360
- ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1361
- : 0;
1464
+ const elapsedSeconds = busyElapsedSeconds(state, Date.now());
1362
1465
  bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1363
1466
  };
1364
1467
  const remountTui = async () => {
@@ -1484,15 +1587,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1484
1587
  }
1485
1588
  };
1486
1589
  const dismissPendingApproval = () => {
1487
- if (!resolveApprovalChoice)
1590
+ if (!resolvePermissionDecision)
1488
1591
  return;
1489
- const pendingResolve = resolveApprovalChoice;
1490
- resolveApprovalChoice = null;
1491
- pendingResolve('n');
1592
+ const pendingResolve = resolvePermissionDecision;
1593
+ resolvePermissionDecision = null;
1594
+ pendingResolve({ kind: 'deny' });
1492
1595
  store.update((current) => ({
1493
- ...current,
1494
- approvalCursor: getDefaultApprovalCursor(),
1596
+ ...resumeBusyClock(current, Date.now()),
1597
+ approvalCursor: 0,
1495
1598
  approvalPrompt: null,
1599
+ approvalScrollOffset: 0,
1496
1600
  }));
1497
1601
  };
1498
1602
  const dismissPendingSudoPassword = () => {
@@ -1505,17 +1609,34 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1505
1609
  sudoPasswordBuffer = '';
1506
1610
  pendingResolve(null);
1507
1611
  store.update((current) => ({
1508
- ...current,
1612
+ ...resumeBusyClock(current, Date.now()),
1509
1613
  sudoPrompt: null,
1510
1614
  }));
1511
1615
  };
1616
+ const dismissPendingUserInput = () => {
1617
+ const pending = pendingUserInput;
1618
+ if (!pending)
1619
+ return;
1620
+ pendingUserInput = null;
1621
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1622
+ const error = new Error('Turn cancelled.');
1623
+ error.name = 'AbortError';
1624
+ pending.reject(error);
1625
+ store.update((current) => ({
1626
+ ...resumeBusyClock(current, Date.now()),
1627
+ status: current.userInputPrompt?.returnStatus ?? current.status,
1628
+ userInputPrompt: null,
1629
+ }));
1630
+ };
1512
1631
  const cancelActiveTurn = () => {
1513
- if (!store.getState().busy)
1632
+ if (!store.getState().busy || newConversationInFlight)
1514
1633
  return;
1515
1634
  disarmExitConfirm();
1516
1635
  dismissPendingApproval();
1517
1636
  dismissPendingSudoPassword();
1637
+ dismissPendingUserInput();
1518
1638
  activeTurnGeneration += 1;
1639
+ resetThinkingPacer();
1519
1640
  activeTurnAbort?.abort();
1520
1641
  activeTurnAbort = null;
1521
1642
  cancelActiveCommand();
@@ -1533,6 +1654,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1533
1654
  activeTurnInput: '',
1534
1655
  activeTurnInputPreformatted: false,
1535
1656
  busy: false,
1657
+ busyPausedAt: null,
1536
1658
  busySince: null,
1537
1659
  commandLog: [],
1538
1660
  cursor: queued ? queued.body.length : current.cursor,
@@ -1545,8 +1667,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1545
1667
  status: 'Ready',
1546
1668
  thinkingTitle: '',
1547
1669
  thinkingNotes: [],
1670
+ turnMessages: [],
1548
1671
  workingTools: [],
1549
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
1672
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
1550
1673
  }));
1551
1674
  appendStaticEntries(cancelledEntries);
1552
1675
  lastTurnStartedAt = null;
@@ -1554,14 +1677,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1554
1677
  scheduleLiveFrameRemount();
1555
1678
  void remountTui();
1556
1679
  };
1557
- const saveActiveSession = async () => {
1558
- try {
1559
- await saveSessionBoth({ serverSessionClient, session });
1560
- }
1561
- catch (error) {
1562
- appendError(`Session save failed: ${error.message}`);
1563
- }
1564
- };
1565
1680
  const syncShellStateFromSession = () => {
1566
1681
  store.update((current) => ({
1567
1682
  ...current,
@@ -1640,6 +1755,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1640
1755
  }
1641
1756
  dismissPendingApproval();
1642
1757
  dismissPendingSudoPassword();
1758
+ dismissPendingUserInput();
1643
1759
  exiting = true;
1644
1760
  killAllBackgroundJobs();
1645
1761
  store.update((current) => ({
@@ -1649,9 +1765,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1649
1765
  status: 'Exiting...',
1650
1766
  }));
1651
1767
  void bridge.close().then(() => {
1768
+ releaseTerminalWrites();
1652
1769
  resolveDone?.();
1653
1770
  });
1654
1771
  };
1772
+ const exitForAuthenticationError = (error) => {
1773
+ if (!isAuthenticationError(error))
1774
+ return false;
1775
+ clearCliAuthConfig(session.env);
1776
+ if (sessionHasUserMessage(session)) {
1777
+ saveSessionState(session);
1778
+ }
1779
+ fatalError = new Error(authenticationErrorMessage(error));
1780
+ requestExit();
1781
+ return true;
1782
+ };
1783
+ const saveActiveSession = async () => {
1784
+ try {
1785
+ await saveSessionBoth({ serverSessionClient, session });
1786
+ return true;
1787
+ }
1788
+ catch (error) {
1789
+ if (exitForAuthenticationError(error))
1790
+ return false;
1791
+ appendError(`Session save failed: ${error.message}`);
1792
+ return true;
1793
+ }
1794
+ };
1795
+ const saveActiveSessionOrAbort = async () => {
1796
+ try {
1797
+ await saveSessionBoth({ serverSessionClient, session });
1798
+ return true;
1799
+ }
1800
+ catch (error) {
1801
+ if (exitForAuthenticationError(error))
1802
+ return false;
1803
+ appendError(`Session save failed: ${error.message}`);
1804
+ return false;
1805
+ }
1806
+ };
1655
1807
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1656
1808
  if (exiting || signal?.aborted) {
1657
1809
  resolve(null);
@@ -1669,7 +1821,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1669
1821
  cleanupSudoPasswordPrompt = null;
1670
1822
  }
1671
1823
  store.update((current) => ({
1672
- ...current,
1824
+ ...pauseBusyClock(current, Date.now()),
1673
1825
  sudoPrompt: {
1674
1826
  command,
1675
1827
  passwordLength: 0,
@@ -1689,7 +1841,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1689
1841
  cleanupSudoPasswordPrompt = null;
1690
1842
  sudoPasswordBuffer = '';
1691
1843
  store.update((current) => ({
1692
- ...current,
1844
+ ...resumeBusyClock(current, Date.now()),
1693
1845
  sudoPrompt: null,
1694
1846
  status: current.sudoPrompt?.returnStatus ?? current.status,
1695
1847
  }));
@@ -1703,7 +1855,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1703
1855
  cleanupSudoPasswordPrompt = null;
1704
1856
  sudoPasswordBuffer = '';
1705
1857
  store.update((current) => ({
1706
- ...current,
1858
+ ...resumeBusyClock(current, Date.now()),
1707
1859
  sudoPrompt: null,
1708
1860
  status: current.sudoPrompt?.returnStatus ?? current.status,
1709
1861
  }));
@@ -1723,38 +1875,88 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1723
1875
  : null,
1724
1876
  }));
1725
1877
  };
1726
- const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1878
+ const openUserInputPrompt = (questions, signal) => new Promise((resolve, reject) => {
1879
+ if (exiting || signal?.aborted) {
1880
+ const error = new Error('Turn cancelled.');
1881
+ error.name = 'AbortError';
1882
+ reject(error);
1883
+ return;
1884
+ }
1885
+ const onAbort = () => dismissPendingUserInput();
1886
+ pendingUserInput = {
1887
+ questions,
1888
+ resolve,
1889
+ reject,
1890
+ signal,
1891
+ onAbort,
1892
+ };
1893
+ signal?.addEventListener('abort', onAbort, { once: true });
1894
+ store.update((current) => ({
1895
+ ...pauseBusyClock(current, Date.now()),
1896
+ status: 'Waiting for your input',
1897
+ userInputPrompt: createUserInputPromptState(questions, current.status),
1898
+ }));
1899
+ scheduleLiveFrameRemount();
1900
+ });
1901
+ const handleInlineUserInput = async (result) => {
1902
+ const pending = pendingUserInput;
1903
+ const prompt = store.getState().userInputPrompt;
1904
+ if (!pending || !prompt)
1905
+ return;
1906
+ pendingUserInput = null;
1907
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1908
+ store.update((current) => ({
1909
+ ...resumeBusyClock(current, Date.now()),
1910
+ status: prompt.returnStatus,
1911
+ userInputPrompt: null,
1912
+ }));
1913
+ appendTurnAwareEntry({
1914
+ body: formatUserInputTranscript(pending.questions, result),
1915
+ kind: result.status === 'submitted' ? 'user' : 'system',
1916
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
1917
+ });
1918
+ pending.resolve(result);
1919
+ scheduleLiveFrameRemount();
1920
+ };
1921
+ const openApprovalPrompt = (request) => new Promise((resolve) => {
1922
+ const deny = { kind: 'deny' };
1727
1923
  if (exiting) {
1728
- resolve('n');
1924
+ resolve(deny);
1729
1925
  return;
1730
1926
  }
1731
- resolveApprovalChoice = resolve;
1927
+ resolvePermissionDecision = resolve;
1732
1928
  store.update((current) => ({
1733
- ...current,
1734
- approvalCursor: getDefaultApprovalCursor(),
1929
+ ...pauseBusyClock(current, Date.now()),
1930
+ approvalCursor: getDefaultApprovalCursor(request.options.length),
1931
+ approvalOpenedAt: Date.now(),
1932
+ approvalScrollOffset: 0,
1735
1933
  approvalPrompt: {
1736
- title,
1737
- body,
1738
- diffPreview: options.diff && options.filePath
1739
- ? parseDiffPreview(options.diff)
1934
+ title: request.title,
1935
+ body: request.body,
1936
+ diffPreview: request.diff && request.filePath
1937
+ ? parseDiffPreview(request.diff)
1740
1938
  : undefined,
1741
- filePath: options.filePath,
1939
+ filePath: request.filePath,
1940
+ options: request.options,
1742
1941
  returnStatus: current.status,
1743
1942
  },
1744
- status: title,
1943
+ status: request.title,
1745
1944
  }));
1746
1945
  });
1747
- const handleInlineApprovalChoice = async (choice) => {
1946
+ const handleInlineApprovalChoice = async (index) => {
1748
1947
  const current = store.getState();
1749
- const pendingResolve = resolveApprovalChoice;
1750
- resolveApprovalChoice = null;
1948
+ const options = current.approvalPrompt?.options ?? [];
1949
+ const chosen = options[index]?.decision ?? { kind: 'deny' };
1950
+ const pendingResolve = resolvePermissionDecision;
1951
+ resolvePermissionDecision = null;
1751
1952
  store.update((next) => ({
1752
- ...next,
1753
- approvalCursor: getDefaultApprovalCursor(),
1953
+ ...resumeBusyClock(next, Date.now()),
1954
+ approvalCursor: getDefaultApprovalCursor(options.length),
1754
1955
  approvalPrompt: null,
1956
+ approvalScrollOffset: 0,
1755
1957
  status: current.approvalPrompt?.returnStatus ?? next.status,
1756
1958
  }));
1757
- pendingResolve?.(choice);
1959
+ pendingResolve?.(chosen);
1758
1960
  };
1759
1961
  const refreshServerModels = async () => {
1760
1962
  currentServerModels = await models.fetchServerModels({ config: authConfig });
@@ -1788,7 +1990,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1788
1990
  selectedModelId: selected,
1789
1991
  serverModels: fresh,
1790
1992
  });
1791
- await saveActiveSession();
1993
+ if (!(await saveActiveSession()))
1994
+ return;
1792
1995
  syncShellStateFromSession();
1793
1996
  appendStaticEntry({
1794
1997
  body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
@@ -1813,7 +2016,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1813
2016
  const id = jobId.trim();
1814
2017
  await collectBackgroundJobUiOutputMutations({
1815
2018
  session,
1816
- projectIndex,
1817
2019
  jobId: id,
1818
2020
  });
1819
2021
  const job = listBackgroundJobs().find((candidate) => candidate.id === id);
@@ -1845,7 +2047,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1845
2047
  return;
1846
2048
  await collectBackgroundJobUiOutputMutations({
1847
2049
  session,
1848
- projectIndex,
1849
2050
  jobId,
1850
2051
  });
1851
2052
  syncBackgroundJobsState();
@@ -1861,7 +2062,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1861
2062
  const killed = await killBackgroundJob(jobId);
1862
2063
  await collectBackgroundJobUiKillMutations({
1863
2064
  session,
1864
- projectIndex,
1865
2065
  jobId,
1866
2066
  result: killed,
1867
2067
  });
@@ -1907,6 +2107,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1907
2107
  await switchToModel(selected.id);
1908
2108
  }
1909
2109
  catch (error) {
2110
+ if (exitForAuthenticationError(error))
2111
+ return;
1910
2112
  appendError(error.message);
1911
2113
  }
1912
2114
  };
@@ -1956,6 +2158,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1956
2158
  store.update((next) => ({
1957
2159
  ...next,
1958
2160
  busy: true,
2161
+ busyPausedAt: null,
1959
2162
  busySince: resumeStartedAt,
1960
2163
  clockNow: resumeStartedAt,
1961
2164
  status: 'Loading session...',
@@ -1965,14 +2168,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1965
2168
  applySessionSnapshot(session, snapshot);
1966
2169
  setBackgroundJobSession(session.sessionId);
1967
2170
  setScratchSession(session.sessionId);
2171
+ setImageStoreSession(session.sessionId);
1968
2172
  syncBackgroundJobsState();
1969
2173
  setTodoSession(session.sessionId);
1970
2174
  syncTodosState();
1971
- await saveActiveSession();
1972
2175
  syncShellStateFromSession();
1973
2176
  store.update((next) => ({
1974
2177
  ...next,
1975
2178
  busy: false,
2179
+ busyPausedAt: null,
1976
2180
  busySince: null,
1977
2181
  resumePickerFilter: '',
1978
2182
  resumePickerIndex: 0,
@@ -1991,9 +2195,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1991
2195
  await remountTui();
1992
2196
  }
1993
2197
  catch (error) {
2198
+ if (exitForAuthenticationError(error))
2199
+ return;
1994
2200
  store.update((next) => ({
1995
2201
  ...next,
1996
2202
  busy: false,
2203
+ busyPausedAt: null,
1997
2204
  busySince: null,
1998
2205
  resumePickerFilter: '',
1999
2206
  resumePickerIndex: 0,
@@ -2017,6 +2224,127 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2017
2224
  }));
2018
2225
  await handleSubmit(queued.body);
2019
2226
  };
2227
+ let turnMessageCounter = 0;
2228
+ const upsertTurnMessage = (id, patch) => {
2229
+ store.update((current) => ({
2230
+ ...current,
2231
+ turnMessages: current.turnMessages.map((message) => message.id === id ? { ...message, ...patch } : message),
2232
+ }));
2233
+ };
2234
+ const recoverUnacknowledgedMessages = (autoSubmits = true) => {
2235
+ if (unacknowledged.size === 0)
2236
+ return;
2237
+ const stranded = [...unacknowledged.values()];
2238
+ unacknowledged = new Map();
2239
+ for (const { row } of stranded) {
2240
+ upsertTurnMessage(row, {
2241
+ state: 'queued',
2242
+ note: autoSubmits
2243
+ ? 'not read — sending as the next prompt'
2244
+ : 'not read — left in the composer',
2245
+ });
2246
+ }
2247
+ const existing = store.getState().queuedMessage;
2248
+ const combined = [
2249
+ ...stranded.map((entry) => entry.queued),
2250
+ ...(existing ? [existing] : []),
2251
+ ];
2252
+ const body = combined
2253
+ .map((entry) => entry.body)
2254
+ .filter((entry) => entry.trim())
2255
+ .join('\n\n');
2256
+ if (!body.trim())
2257
+ return;
2258
+ scheduleLiveFrameRemount();
2259
+ store.update((current) => ({
2260
+ ...current,
2261
+ queuedMessage: {
2262
+ body,
2263
+ imageAttachments: combined.flatMap((entry) => entry.imageAttachments),
2264
+ pastedChunks: combined.flatMap((entry) => entry.pastedChunks),
2265
+ },
2266
+ }));
2267
+ };
2268
+ const fireQueuedMessage = async () => {
2269
+ const state = store.getState();
2270
+ const queued = state.queuedMessage;
2271
+ if (!queued || !state.busy || exiting)
2272
+ return;
2273
+ const text = (queued.pastedChunks.length
2274
+ ? expandPastedChunks(queued.body, queued.pastedChunks)
2275
+ : queued.body).trim();
2276
+ const images = queued.imageAttachments.filter((attachment) => queued.body.includes(`[Image #${attachment.index}]`));
2277
+ if (!text && images.length === 0)
2278
+ return;
2279
+ const turnId = activeServerTurnId;
2280
+ const blockedReason = !turnId ? 'waiting for the turn to end' : null;
2281
+ const existingRow = blockedRows.get(queued);
2282
+ if (existingRow !== undefined) {
2283
+ upsertTurnMessage(existingRow, {
2284
+ state: 'queued',
2285
+ ...(blockedReason ? { note: blockedReason } : {}),
2286
+ });
2287
+ if (blockedReason || !turnId)
2288
+ return;
2289
+ }
2290
+ const id = existingRow ?? ++turnMessageCounter;
2291
+ const messageId = `m${id}_${Date.now().toString(36)}`;
2292
+ if (existingRow === undefined) {
2293
+ scheduleLiveFrameRemount();
2294
+ store.update((current) => ({
2295
+ ...current,
2296
+ turnMessages: [
2297
+ ...current.turnMessages,
2298
+ {
2299
+ id,
2300
+ text,
2301
+ state: blockedReason ? 'queued' : 'sending',
2302
+ ...(blockedReason ? { note: blockedReason } : {}),
2303
+ },
2304
+ ],
2305
+ }));
2306
+ }
2307
+ if (blockedReason || !turnId) {
2308
+ blockedRows.set(queued, id);
2309
+ return;
2310
+ }
2311
+ blockedRows.delete(queued);
2312
+ unacknowledged.set(messageId, { row: id, queued });
2313
+ scheduleLiveFrameRemount();
2314
+ store.update((current) => current.queuedMessage === queued
2315
+ ? { ...current, queuedMessage: null }
2316
+ : current);
2317
+ queueTurnEntry({
2318
+ body: text,
2319
+ kind: 'user',
2320
+ preformatted: queued.pastedChunks.length > 0,
2321
+ title: 'You · sent mid-turn',
2322
+ });
2323
+ const rollback = (note) => {
2324
+ if (!unacknowledged.delete(messageId))
2325
+ return;
2326
+ upsertTurnMessage(id, { state: 'queued', note });
2327
+ scheduleLiveFrameRemount();
2328
+ store.update((current) => current.queuedMessage ? current : { ...current, queuedMessage: queued });
2329
+ };
2330
+ let outcome;
2331
+ try {
2332
+ outcome = await chat.postInterjection({
2333
+ config: authConfig,
2334
+ turnId,
2335
+ text,
2336
+ messageId,
2337
+ imageAttachments: images,
2338
+ });
2339
+ }
2340
+ catch (error) {
2341
+ rollback(`not sent — ${error?.message ?? 'request failed'}`);
2342
+ return;
2343
+ }
2344
+ if (outcome === 'stale') {
2345
+ rollback('turn ended — sending as the next prompt');
2346
+ }
2347
+ };
2020
2348
  const handleSubmit = async (rawInput) => {
2021
2349
  const chunks = store.getState().pastedChunks;
2022
2350
  const expanded = chunks.length
@@ -2036,9 +2364,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2036
2364
  return;
2037
2365
  }
2038
2366
  const pending = store.getState();
2367
+ const body = String(rawInput ?? '');
2039
2368
  const snapshot = {
2040
- body: String(rawInput ?? ''),
2041
- imageAttachments: pending.imageAttachments,
2369
+ body,
2370
+ imageAttachments: pending.imageAttachments.filter((attachment) => body.includes(`[Image #${attachment.index}]`)),
2042
2371
  pastedChunks: pending.pastedChunks,
2043
2372
  };
2044
2373
  scheduleLiveFrameRemount();
@@ -2053,11 +2382,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2053
2382
  }));
2054
2383
  return;
2055
2384
  }
2056
- const imageAttachments = store.getState().imageAttachments;
2385
+ const imageAttachments = store
2386
+ .getState()
2387
+ .imageAttachments.filter((attachment) => input.includes(`[Image #${attachment.index}]`));
2057
2388
  scheduleLiveFrameRemount();
2058
2389
  store.update((current) => ({
2059
2390
  ...current,
2060
2391
  cursor: 0,
2392
+ imageAttachments: [],
2061
2393
  input: '',
2062
2394
  pastedChunks: [],
2063
2395
  promptHistory: appendPromptToHistory(current.promptHistory, input),
@@ -2073,6 +2405,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2073
2405
  requestExit();
2074
2406
  return;
2075
2407
  }
2408
+ if (input === '/logout') {
2409
+ store.update((current) => ({
2410
+ ...current,
2411
+ busy: true,
2412
+ status: 'Signing out...',
2413
+ }));
2414
+ try {
2415
+ await logoutFromServer({ config: authConfig });
2416
+ }
2417
+ catch {
2418
+ }
2419
+ clearCliAuthConfig(session.env);
2420
+ signedOut = true;
2421
+ requestExit();
2422
+ return;
2423
+ }
2076
2424
  if (input === '/help') {
2077
2425
  appendStaticEntry({
2078
2426
  body: formatInteractiveHelpText(),
@@ -2101,7 +2449,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2101
2449
  const killed = await killBackgroundJob(jobId);
2102
2450
  await collectBackgroundJobUiKillMutations({
2103
2451
  session,
2104
- projectIndex,
2105
2452
  jobId,
2106
2453
  result: killed,
2107
2454
  });
@@ -2143,14 +2490,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2143
2490
  });
2144
2491
  }
2145
2492
  catch (error) {
2493
+ if (exitForAuthenticationError(error))
2494
+ return;
2146
2495
  appendError(error.message);
2147
2496
  }
2148
2497
  finally {
2149
- store.update((current) => ({
2150
- ...current,
2151
- busy: false,
2152
- status: 'Ready',
2153
- }));
2498
+ if (!exiting) {
2499
+ store.update((current) => ({
2500
+ ...current,
2501
+ busy: false,
2502
+ status: 'Ready',
2503
+ }));
2504
+ }
2154
2505
  }
2155
2506
  return;
2156
2507
  }
@@ -2164,14 +2515,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2164
2515
  await openResumePicker();
2165
2516
  }
2166
2517
  catch (error) {
2518
+ if (exitForAuthenticationError(error))
2519
+ return;
2167
2520
  appendError(error.message);
2168
2521
  }
2169
2522
  finally {
2170
- store.update((current) => ({
2171
- ...current,
2172
- busy: false,
2173
- status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2174
- }));
2523
+ if (!exiting) {
2524
+ store.update((current) => ({
2525
+ ...current,
2526
+ busy: false,
2527
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2528
+ }));
2529
+ }
2175
2530
  }
2176
2531
  return;
2177
2532
  }
@@ -2191,43 +2546,85 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2191
2546
  }
2192
2547
  }
2193
2548
  catch (error) {
2549
+ if (exitForAuthenticationError(error))
2550
+ return;
2194
2551
  appendError(error.message);
2195
2552
  }
2196
2553
  finally {
2554
+ if (!exiting) {
2555
+ store.update((current) => ({
2556
+ ...current,
2557
+ busy: false,
2558
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2559
+ }));
2560
+ }
2561
+ }
2562
+ return;
2563
+ }
2564
+ if (input === '/new') {
2565
+ newConversationInFlight = true;
2566
+ store.update((current) => ({
2567
+ ...current,
2568
+ busy: true,
2569
+ busyPausedAt: null,
2570
+ busySince: Date.now(),
2571
+ imageAttachments: [],
2572
+ queuedMessage: null,
2573
+ status: 'Starting a new conversation...',
2574
+ }));
2575
+ if (!(await saveActiveSessionOrAbort())) {
2576
+ newConversationInFlight = false;
2197
2577
  store.update((current) => ({
2198
2578
  ...current,
2199
2579
  busy: false,
2200
- status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2580
+ busyPausedAt: null,
2581
+ busySince: null,
2582
+ status: 'Ready',
2201
2583
  }));
2584
+ await flushQueuedMessage();
2585
+ return;
2202
2586
  }
2203
- return;
2204
- }
2205
- if (input === '/clear') {
2206
- clearConversation(session);
2587
+ startNewConversation(session);
2588
+ setBackgroundJobSession(session.sessionId);
2589
+ setScratchSession(session.sessionId);
2590
+ setImageStoreSession(session.sessionId);
2591
+ setTodoSession(session.sessionId);
2207
2592
  clearTodos();
2593
+ syncBackgroundJobsState();
2208
2594
  syncTodosState();
2209
2595
  latestUsageSummary = null;
2210
- await saveActiveSession();
2211
2596
  store.replaceTranscript([
2212
2597
  {
2213
- body: 'Conversation cleared.',
2598
+ body: 'Started a new conversation. The previous session remains saved.',
2214
2599
  kind: 'system',
2215
- title: 'System',
2600
+ title: 'Session',
2216
2601
  },
2217
2602
  ]);
2603
+ await saveActiveSession();
2218
2604
  syncShellStateFromSession();
2219
- store.update((current) => ({ ...current, queuedMessage: null }));
2605
+ newConversationInFlight = false;
2606
+ store.update((current) => ({
2607
+ ...current,
2608
+ busy: false,
2609
+ busyPausedAt: null,
2610
+ busySince: null,
2611
+ status: 'Ready',
2612
+ }));
2220
2613
  await remountTui();
2614
+ await flushQueuedMessage();
2221
2615
  return;
2222
2616
  }
2223
2617
  latestUsageSummary = null;
2224
2618
  disarmExitConfirm();
2619
+ unacknowledged = new Map();
2225
2620
  const turnStartedAt = Date.now();
2226
2621
  const turnGeneration = ++activeTurnGeneration;
2227
2622
  const turnAbort = new AbortController();
2228
2623
  activeTurnAbort = turnAbort;
2229
2624
  lastTurnStartedAt = turnStartedAt;
2230
2625
  todosTouchedThisTurn = false;
2626
+ clearTodos();
2627
+ syncTodosState();
2231
2628
  const userEntry = {
2232
2629
  body: input,
2233
2630
  kind: 'user',
@@ -2236,11 +2633,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2236
2633
  };
2237
2634
  pendingTurnEntries = [];
2238
2635
  queueTurnEntry(userEntry);
2636
+ resetThinkingPacer();
2239
2637
  store.update((current) => ({
2240
2638
  ...current,
2241
2639
  activeTurnInput: input,
2242
2640
  activeTurnInputPreformatted: preformatted,
2641
+ analyzingImages: 0,
2642
+ generatingImage: false,
2243
2643
  busy: true,
2644
+ busyPausedAt: null,
2244
2645
  busySince: turnStartedAt,
2245
2646
  clockNow: turnStartedAt,
2246
2647
  status: 'Running turn...',
@@ -2249,22 +2650,42 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2249
2650
  transcriptScrollOffset: 0,
2250
2651
  tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
2251
2652
  turnCounter: current.turnCounter + 1,
2653
+ turnMessages: [],
2252
2654
  workingTools: [],
2253
2655
  }));
2254
2656
  try {
2255
2657
  const result = await chat.sendServerUserMessage({
2256
2658
  config: authConfig,
2257
- projectIndex,
2258
2659
  session,
2259
2660
  input,
2260
2661
  imageAttachments,
2261
2662
  signal: turnAbort.signal,
2663
+ onTurnStart: (serverTurnId) => {
2664
+ if (turnGeneration !== activeTurnGeneration)
2665
+ return;
2666
+ activeServerTurnId = serverTurnId;
2667
+ },
2668
+ onInterjectionDelivered: (event) => {
2669
+ if (turnGeneration !== activeTurnGeneration)
2670
+ return;
2671
+ for (const messageId of event.messageIds ?? []) {
2672
+ const pending = unacknowledged.get(messageId);
2673
+ if (!pending)
2674
+ continue;
2675
+ unacknowledged.delete(messageId);
2676
+ upsertTurnMessage(pending.row, {
2677
+ state: 'delivered',
2678
+ note: undefined,
2679
+ });
2680
+ }
2681
+ },
2262
2682
  });
2263
2683
  if (turnGeneration !== activeTurnGeneration)
2264
2684
  return;
2265
2685
  latestUsageSummary = result.usageSummary ?? null;
2266
2686
  const turnEntries = takePendingTurnEntries();
2267
- await saveActiveSession();
2687
+ if (!(await saveActiveSession()))
2688
+ return;
2268
2689
  syncShellStateFromSession();
2269
2690
  store.update((current) => ({
2270
2691
  ...current,
@@ -2301,10 +2722,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2301
2722
  store.update((current) => ({
2302
2723
  ...current,
2303
2724
  busy: false,
2725
+ busyPausedAt: null,
2304
2726
  busySince: null,
2305
2727
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2306
2728
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2307
2729
  }));
2730
+ recoverUnacknowledgedMessages();
2308
2731
  await flushQueuedMessage();
2309
2732
  }
2310
2733
  catch (error) {
@@ -2315,11 +2738,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2315
2738
  return;
2316
2739
  }
2317
2740
  const cancelled = isTurnCancelledError(error);
2741
+ if (exitForAuthenticationError(error))
2742
+ return;
2318
2743
  store.update((current) => ({
2319
2744
  ...current,
2320
2745
  activeTurnInput: '',
2321
2746
  activeTurnInputPreformatted: false,
2322
2747
  busy: false,
2748
+ busyPausedAt: null,
2323
2749
  busySince: null,
2324
2750
  commandLog: [],
2325
2751
  imageAttachments: [],
@@ -2339,13 +2765,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2339
2765
  title: 'System',
2340
2766
  },
2341
2767
  ]);
2342
- await saveActiveSession();
2768
+ if (!(await saveActiveSession()))
2769
+ return;
2343
2770
  }
2344
2771
  else {
2345
2772
  appendStaticEntries(turnEntries);
2346
2773
  appendError(error.message);
2347
2774
  }
2348
2775
  await remountTui();
2776
+ recoverUnacknowledgedMessages(!cancelled);
2349
2777
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2350
2778
  await flushQueuedMessage();
2351
2779
  }
@@ -2356,24 +2784,42 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2356
2784
  }
2357
2785
  if (turnGeneration === activeTurnGeneration) {
2358
2786
  lastTurnStartedAt = null;
2787
+ activeServerTurnId = null;
2359
2788
  }
2789
+ recoverUnacknowledgedMessages();
2360
2790
  }
2361
2791
  };
2792
+ session.onImageAnalysis = (activeImageCount) => {
2793
+ store.update((current) => ({
2794
+ ...current,
2795
+ analyzingImages: Math.max(0, activeImageCount),
2796
+ generatingImage: activeImageCount > 0 ? false : current.generatingImage,
2797
+ }));
2798
+ };
2799
+ session.onImageGeneration = (active) => {
2800
+ store.update((current) => ({
2801
+ ...current,
2802
+ generatingImage: Boolean(active),
2803
+ analyzingImages: active ? 0 : current.analyzingImages,
2804
+ }));
2805
+ };
2362
2806
  session.onStatus = (message) => {
2363
2807
  const panel = thinkingPanelFromStatus(message);
2364
2808
  store.update((current) => ({
2365
2809
  ...current,
2366
2810
  status: message,
2367
- thinkingTitle: panel?.title ?? current.thinkingTitle,
2368
- thinkingNotes: panel?.notes ?? current.thinkingNotes,
2369
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2811
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2370
2812
  }));
2813
+ queueThinkingUpdate({
2814
+ title: panel ? panel.title : liveStatusPanelTitle(message),
2815
+ notes: panel ? panel.notes : [],
2816
+ });
2371
2817
  };
2372
2818
  session.onContextLog = (message) => {
2373
2819
  store.update((current) => ({
2374
2820
  ...current,
2375
2821
  contextStatus: message,
2376
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2822
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2377
2823
  }));
2378
2824
  };
2379
2825
  session.onToolEvent = (event) => {
@@ -2439,54 +2885,46 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2439
2885
  appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2440
2886
  }
2441
2887
  });
2442
- projectIndex.onStatus = session.onStatus;
2443
- projectIndex.onContextLog = session.onContextLog;
2444
2888
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2445
- session.confirmCommand = async (command) => {
2889
+ session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
2890
+ session.requestPermission = async (request) => {
2891
+ const deny = { kind: 'deny' };
2446
2892
  if (exiting)
2447
- return false;
2893
+ return deny;
2448
2894
  if (sessionAutoYes)
2449
- return true;
2450
- const choice = await openApprovalPrompt('Approve command?', command);
2451
- if (choice === 'a') {
2452
- setAgentMode('auto-accept');
2453
- syncShellStateFromSession();
2895
+ return { kind: 'once' };
2896
+ const decision = await openApprovalPrompt(request);
2897
+ if (decision.kind === 'always-bucket') {
2454
2898
  appendTurnAwareEntry({
2455
- body: 'Auto-approve enabled for the rest of this session.',
2899
+ body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
2456
2900
  kind: 'system',
2457
2901
  title: 'Approvals',
2458
2902
  });
2459
- return true;
2460
2903
  }
2461
- return choice === 'y';
2462
- };
2463
- session.confirmPatch = async (filePath, patch) => {
2464
- if (exiting)
2465
- return false;
2466
- if (sessionAutoYes)
2467
- return true;
2468
- const choice = await openApprovalPrompt('Approve patch?', 'Review changes before applying.', {
2469
- diff: patch,
2470
- filePath,
2471
- });
2472
- if (choice === 'a') {
2473
- setAgentMode('auto-accept');
2474
- syncShellStateFromSession();
2904
+ else if (decision.kind === 'always-prefix') {
2475
2905
  appendTurnAwareEntry({
2476
- body: 'Auto-approve enabled for the rest of this session.',
2906
+ body: `Allowed for the rest of this session: commands starting with \`${decision.prefix}\`.`,
2477
2907
  kind: 'system',
2478
2908
  title: 'Approvals',
2479
2909
  });
2480
- return true;
2481
2910
  }
2482
- return choice === 'y';
2911
+ return decision;
2483
2912
  };
2484
2913
  const shellInputHandlers = {
2914
+ getApprovalScrollLimit: () => {
2915
+ const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2916
+ return approvalScrollLimit(store.getState().approvalPrompt, contentWidth, terminalRows);
2917
+ },
2485
2918
  getTranscriptScrollLimit: () => {
2486
2919
  const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2487
2920
  const blocks = store.getState().transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
2488
2921
  return blocks.reduce((total, block, index) => total + block.length + (index > 0 ? 1 : 0), 0);
2489
2922
  },
2923
+ getUserInputViewport: () => {
2924
+ const nowMs = Date.now();
2925
+ const state = store.getState();
2926
+ return userInputViewportForFrame(state, terminalCols, terminalRows, spinnerFrame, busyElapsedSeconds(state, nowMs), nowMs);
2927
+ },
2490
2928
  onCancelTurn: cancelActiveTurn,
2491
2929
  onCycleAgentMode: cycleAgentMode,
2492
2930
  onCtrlC: handleCtrlC,
@@ -2495,6 +2933,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2495
2933
  onLiveFrameShapeChange: scheduleLiveFrameRemount,
2496
2934
  onRequestExit: requestExit,
2497
2935
  onResolveApproval: handleInlineApprovalChoice,
2936
+ onResolveUserInput: handleInlineUserInput,
2498
2937
  onResumeSession: handleInlineResumeSelection,
2499
2938
  onSelectionCopy: handleAppSelectionCopy,
2500
2939
  onSelectModel: handleInlineModelSelection,
@@ -2502,6 +2941,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2502
2941
  onJobsPickerKill: handleJobsPickerKill,
2503
2942
  onSudoPasswordInput: handleSudoPasswordInput,
2504
2943
  onSubmit: handleSubmit,
2944
+ onFireQueuedMessage: fireQueuedMessage,
2505
2945
  };
2506
2946
  const unsubscribe = store.subscribe(() => {
2507
2947
  renderCurrentFrame();
@@ -2509,6 +2949,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2509
2949
  const spinnerTimer = setInterval(() => {
2510
2950
  spinnerFrame = (spinnerFrame + 1) % 6;
2511
2951
  if (store.getState().busy) {
2952
+ flushPendingThinking();
2512
2953
  renderCurrentFrame();
2513
2954
  }
2514
2955
  }, 120);
@@ -2564,10 +3005,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2564
3005
  killAllBackgroundJobs();
2565
3006
  setBackgroundJobSession(null);
2566
3007
  setScratchSession(null);
3008
+ setImageStoreSession(null);
2567
3009
  setTodoSession(null);
2568
3010
  setBackgroundJobUpdateHook(null);
2569
3011
  await bridge.close();
3012
+ releaseTerminalWrites();
2570
3013
  setCommandOutputHook(null);
2571
3014
  }
2572
3015
  });
3016
+ if (fatalError)
3017
+ throw fatalError;
3018
+ return { signedOut };
2573
3019
  }