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

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 +67 -18
  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 +649 -164
  40. package/dist/src/ui/tui/bridge.js +10 -0
  41. package/dist/src/ui/tui/build-frame.js +453 -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,35 @@ 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
- ];
79
+ export const REPORT_ISSUE_URL = 'https://github.com/thegitai/thegitai-cli/issues/new';
80
+ export function buildReportTranscriptEntries(openedUrl) {
81
+ return openedUrl
82
+ ? [
83
+ {
84
+ body: `Opening the public GitHub issue form in your browser. If nothing opened, visit:\n\n${REPORT_ISSUE_URL}\n\nThe tracker is public — do not paste secrets or code you may not share.`,
85
+ kind: 'system',
86
+ title: 'Report',
87
+ },
88
+ ]
89
+ : [
90
+ {
91
+ body: `Could not open a browser. Report the bug manually at:\n\n${REPORT_ISSUE_URL}\n`,
92
+ kind: 'error',
93
+ title: 'Report',
94
+ },
95
+ ];
96
+ }
97
+ export function buildTurnFailureHintEntries(cancelled) {
98
+ if (cancelled)
99
+ return [];
100
+ return [
101
+ {
102
+ body: 'Something went wrong? Use /report to submit the issue.',
103
+ kind: 'error',
104
+ title: 'Report',
105
+ },
106
+ ];
107
+ }
89
108
  export const SLASH_COMMANDS = [
90
109
  {
91
110
  command: '/help',
@@ -95,6 +114,10 @@ export const SLASH_COMMANDS = [
95
114
  command: '/about',
96
115
  description: 'Show version and platform info',
97
116
  },
117
+ {
118
+ command: '/report',
119
+ description: 'Report a bug: opens the public GitHub issue form',
120
+ },
98
121
  {
99
122
  command: '/usage',
100
123
  description: 'Show account usage percentage and reset times',
@@ -112,8 +135,12 @@ export const SLASH_COMMANDS = [
112
135
  description: 'Background jobs: pick to view output or kill',
113
136
  },
114
137
  {
115
- command: '/clear',
116
- description: 'Clear conversation history',
138
+ command: '/new',
139
+ description: 'start a new conversation; this session remains saved',
140
+ },
141
+ {
142
+ command: '/logout',
143
+ description: 'Sign out and quit',
117
144
  },
118
145
  {
119
146
  command: '/exit',
@@ -514,6 +541,8 @@ function formatToolResultState(result) {
514
541
  return 'Blocked';
515
542
  if (result?.ok === true)
516
543
  return 'OK';
544
+ if (Number.isInteger(result?.httpStatus))
545
+ return String(result.httpStatus);
517
546
  return 'Failed';
518
547
  }
519
548
  function formatRunCommandResultState(result) {
@@ -670,7 +699,7 @@ function buildFileChangeEntry(event) {
670
699
  title: `Edited ${filePath}${summary}`,
671
700
  };
672
701
  }
673
- function buildWorkingToolEntry(event) {
702
+ export function buildWorkingToolEntry(event) {
674
703
  const { call, result } = event;
675
704
  const error = typeof result?.error === 'string' && result.error.trim()
676
705
  ? `\n${truncate(result.error.trim(), 180)}`
@@ -780,13 +809,21 @@ function displayUserTextFromHistoryEntry(entry) {
780
809
  .slice(contentStart, contentEnd === -1 ? text.length : contentEnd)
781
810
  .trim();
782
811
  }
783
- function buildTranscriptFromSessionHistory(history) {
812
+ export function buildTranscriptFromSessionHistory(history) {
784
813
  const entries = [];
785
814
  const pendingCalls = new Map();
815
+ const pendingUserInputRequests = new Map();
786
816
  for (const entry of history) {
787
817
  if (!entry?.parts?.length)
788
818
  continue;
789
819
  for (const part of entry.parts) {
820
+ const userInputRequest = part?.userInputRequest;
821
+ if (userInputRequest && typeof userInputRequest === 'object') {
822
+ const requestId = String(userInputRequest.requestId ?? '').trim();
823
+ if (requestId && Array.isArray(userInputRequest.questions)) {
824
+ pendingUserInputRequests.set(requestId, userInputRequest.questions);
825
+ }
826
+ }
790
827
  const call = part?.functionCall;
791
828
  if (!call || typeof call !== 'object')
792
829
  continue;
@@ -796,6 +833,21 @@ function buildTranscriptFromSessionHistory(history) {
796
833
  pendingCalls.set(callId, call);
797
834
  }
798
835
  for (const part of entry.parts) {
836
+ const userInputResult = part?.userInputResult;
837
+ if (userInputResult && typeof userInputResult === 'object') {
838
+ const requestId = String(userInputResult.requestId ?? '').trim();
839
+ const questions = pendingUserInputRequests.get(requestId);
840
+ const result = userInputResult.result;
841
+ if (questions &&
842
+ (result?.status === 'submitted' || result?.status === 'cancelled')) {
843
+ pendingUserInputRequests.delete(requestId);
844
+ entries.push({
845
+ body: formatUserInputTranscript(questions, result),
846
+ kind: result.status === 'submitted' ? 'user' : 'system',
847
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
848
+ });
849
+ }
850
+ }
799
851
  const functionResponse = part?.functionResponse;
800
852
  if (!functionResponse || typeof functionResponse !== 'object')
801
853
  continue;
@@ -824,8 +876,23 @@ function buildTranscriptFromSessionHistory(history) {
824
876
  }
825
877
  continue;
826
878
  }
879
+ if (entry.role === 'user' && entry.kind === 'userInterjection') {
880
+ const text = String(entry.userInput ?? '').trim();
881
+ if (text) {
882
+ entries.push({ body: text, kind: 'user', title: 'You · sent mid-turn' });
883
+ }
884
+ continue;
885
+ }
827
886
  const text = textFromHistoryEntry(entry);
828
887
  if ((entry.role === 'model' || entry.role === 'assistant') && text) {
888
+ if (isTurnFailureMarker(text)) {
889
+ entries.push({
890
+ body: 'This request did not complete.',
891
+ kind: 'system',
892
+ title: 'Previous turn',
893
+ });
894
+ continue;
895
+ }
829
896
  entries.push({ body: text, kind: 'assistant', title: 'Response' });
830
897
  }
831
898
  }
@@ -951,11 +1018,15 @@ function createInitialShellState(session, serverModels, debugUi) {
951
1018
  activeTurnInput: '',
952
1019
  activeTurnInputPreformatted: false,
953
1020
  agentMode: session.agentMode,
954
- approvalCursor: getDefaultApprovalCursor(),
1021
+ analyzingImages: 0,
1022
+ generatingImage: false,
1023
+ approvalCursor: 0,
955
1024
  approvalPrompt: null,
1025
+ approvalScrollOffset: 0,
956
1026
  autoYes: session.autoYes,
957
1027
  backgroundJobs: [],
958
1028
  busy: false,
1029
+ busyPausedAt: null,
959
1030
  busySince: null,
960
1031
  clockNow: Date.now(),
961
1032
  commandCursor: 0,
@@ -983,6 +1054,7 @@ function createInitialShellState(session, serverModels, debugUi) {
983
1054
  resumePickerSessions: [],
984
1055
  transcriptScrollOffset: 0,
985
1056
  queuedMessage: null,
1057
+ turnMessages: [],
986
1058
  serverModels: serverModels.models,
987
1059
  sudoPrompt: null,
988
1060
  status: 'Ready',
@@ -993,6 +1065,7 @@ function createInitialShellState(session, serverModels, debugUi) {
993
1065
  tokenUsage: formatClientTokenUsage(null),
994
1066
  transcript: [],
995
1067
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
1068
+ userInputPrompt: null,
996
1069
  pastedChunks: [],
997
1070
  imageAttachments: [],
998
1071
  workingTools: [],
@@ -1009,7 +1082,6 @@ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
1009
1082
  const reasoningTokens = usageSummary?.reasoningTokens ?? 0;
1010
1083
  const cacheTokens = usageSummary?.cacheTokens ?? 0;
1011
1084
  const cacheWriteTokens = usageSummary?.cacheWriteTokens ?? 0;
1012
- const indexTokens = usageSummary?.indexTokens ?? 0;
1013
1085
  return [
1014
1086
  'Session tokens',
1015
1087
  `in ${formatTokenCount(inputTokens)}`,
@@ -1017,7 +1089,6 @@ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
1017
1089
  ...(reasoningTokens > 0 ? [`think ${formatTokenCount(reasoningTokens)}`] : []),
1018
1090
  `cache ${formatTokenCount(cacheTokens)}`,
1019
1091
  `write ${formatTokenCount(cacheWriteTokens)}`,
1020
- `index ${formatTokenCount(indexTokens)}`,
1021
1092
  ].join(' • ');
1022
1093
  }
1023
1094
  export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
@@ -1060,6 +1131,24 @@ function splitThinkingLines(text) {
1060
1131
  .map((part) => part.trim())
1061
1132
  .filter(Boolean));
1062
1133
  }
1134
+ const LIVE_STATUS_PANEL_MAX_CHARS = 72;
1135
+ const TOOL_PROGRESS_STATUS_PATTERN = /^(?:Tool:\s|Running\s\S+(?:\slocally)?\.\.\.$)/i;
1136
+ export function isToolProgressStatus(status) {
1137
+ return TOOL_PROGRESS_STATUS_PATTERN.test(String(status ?? '').trim());
1138
+ }
1139
+ function holdOrPickFallback(currentTitle) {
1140
+ return THINKING_FALLBACK_PHRASES.includes(currentTitle.trim())
1141
+ ? currentTitle
1142
+ : pickThinkingFallbackPhrase();
1143
+ }
1144
+ function liveStatusPanelTitle(status) {
1145
+ const text = String(status ?? '').trim();
1146
+ if (!text || text.includes('\n'))
1147
+ return '';
1148
+ if (isToolProgressStatus(text))
1149
+ return '';
1150
+ return text.length <= LIVE_STATUS_PANEL_MAX_CHARS ? text : '';
1151
+ }
1063
1152
  function thinkingPanelFromStatus(status) {
1064
1153
  const rawText = thinkingNoteFromStatus(status);
1065
1154
  if (!rawText)
@@ -1083,7 +1172,7 @@ function thinkingPanelFromStatus(status) {
1083
1172
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
1084
1173
  return {
1085
1174
  title,
1086
- notes: splitThinkingLines(bodyLines.join('\n')).slice(-3),
1175
+ notes: splitThinkingLines(bodyLines.join('\n')).slice(0, 3),
1087
1176
  };
1088
1177
  }
1089
1178
  export const EXIT_CTRL_C_CONFIRM_MESSAGE = 'Press Ctrl+C again to quit.';
@@ -1213,26 +1302,37 @@ export function navigatePromptHistory(state, direction) {
1213
1302
  promptHistoryCursor: nextCursor,
1214
1303
  };
1215
1304
  }
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;
1305
+ export function getDefaultApprovalCursor(optionCount) {
1306
+ return Math.max(0, optionCount - 1);
1307
+ }
1308
+ export function getNextApprovalCursor(currentIndex, direction, optionCount) {
1309
+ if (optionCount <= 0)
1310
+ return 0;
1311
+ return (currentIndex + direction + optionCount) % optionCount;
1312
+ }
1313
+ export function pauseBusyClock(state, nowMs) {
1314
+ if (state.busySince === null || state.busyPausedAt !== null)
1315
+ return state;
1316
+ return { ...state, busyPausedAt: nowMs };
1317
+ }
1318
+ export function resumeBusyClock(state, nowMs) {
1319
+ if (state.busyPausedAt === null)
1320
+ return state;
1321
+ return {
1322
+ ...state,
1323
+ busyPausedAt: null,
1324
+ busySince: state.busySince === null
1325
+ ? null
1326
+ : state.busySince + Math.max(0, nowMs - state.busyPausedAt),
1327
+ };
1328
+ }
1329
+ export function busyElapsedMs(state, nowMs) {
1330
+ if (state.busySince === null)
1331
+ return null;
1332
+ return Math.max(0, (state.busyPausedAt ?? nowMs) - state.busySince);
1333
+ }
1334
+ export function busyElapsedSeconds(state, nowMs) {
1335
+ return Math.floor((busyElapsedMs(state, nowMs) ?? 0) / 1000);
1236
1336
  }
1237
1337
  export function buildModelPickerOptions(currentModelId, serverModels) {
1238
1338
  return serverModels.map((model) => ({
@@ -1270,35 +1370,37 @@ function appendCommandLog(state, text) {
1270
1370
  };
1271
1371
  }
1272
1372
  async function saveSessionBoth({ serverSessionClient, session, }) {
1373
+ if (!sessionHasUserMessage(session))
1374
+ return;
1273
1375
  saveSessionState(session);
1274
1376
  await serverSessionClient.save(session);
1275
1377
  }
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.');
1378
+ export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1379
+ if (process.stdin.isTTY !== true) {
1380
+ throw new Error('stdin is not a terminal');
1381
+ }
1382
+ if (process.stdout.isTTY !== true) {
1383
+ throw new Error('stdout is not a terminal');
1287
1384
  }
1385
+ let fatalError = null;
1386
+ let signedOut = false;
1288
1387
  await withTuiMode(async () => {
1289
1388
  setBackgroundJobSession(session.sessionId);
1290
1389
  setScratchSession(session.sessionId);
1390
+ setImageStoreSession(session.sessionId);
1291
1391
  setTodoSession(session.sessionId);
1292
1392
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1293
1393
  store.replaceTranscript(createSessionTranscript(session));
1294
1394
  let currentServerModels = serverModels;
1295
1395
  let sessionAutoYes = session.autoYes;
1296
1396
  let resolveDone = null;
1297
- let resolveApprovalChoice = null;
1397
+ let resolvePermissionDecision = null;
1298
1398
  let resolveSudoPassword = null;
1399
+ let pendingUserInput = null;
1299
1400
  let cleanupSudoPasswordPrompt = null;
1300
1401
  let sudoPasswordBuffer = '';
1301
1402
  const bridge = createRatatuiBridge();
1403
+ captureTerminalWrites();
1302
1404
  const { handleShellKeyEvent } = await import('./tui/shell-input.js');
1303
1405
  let terminalCols = 80;
1304
1406
  let terminalRows = 24;
@@ -1311,6 +1413,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1311
1413
  let latestUsageSummary = null;
1312
1414
  let pendingTurnEntries = [];
1313
1415
  let activeTurnAbort = null;
1416
+ let activeServerTurnId = null;
1417
+ let unacknowledged = new Map();
1418
+ const blockedRows = new WeakMap();
1419
+ let newConversationInFlight = false;
1314
1420
  let todosTouchedThisTurn = false;
1315
1421
  const syncTodosState = () => {
1316
1422
  store.update((current) => ({ ...current, todos: listTodos() }));
@@ -1319,6 +1425,36 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1319
1425
  let exitCtrlCArmed = false;
1320
1426
  let exitCtrlCTimer = null;
1321
1427
  let transientStatusTimer = null;
1428
+ const THINKING_MIN_DWELL_MS = 3_000;
1429
+ let thinkingAppliedAt = 0;
1430
+ let pendingThinking = null;
1431
+ const applyThinking = (next) => {
1432
+ thinkingAppliedAt = Date.now();
1433
+ pendingThinking = null;
1434
+ store.update((current) => ({
1435
+ ...current,
1436
+ thinkingTitle: next.title || holdOrPickFallback(current.thinkingTitle),
1437
+ thinkingNotes: next.notes,
1438
+ }));
1439
+ };
1440
+ const queueThinkingUpdate = (next) => {
1441
+ if (Date.now() - thinkingAppliedAt >= THINKING_MIN_DWELL_MS) {
1442
+ applyThinking(next);
1443
+ return;
1444
+ }
1445
+ pendingThinking = next;
1446
+ };
1447
+ const flushPendingThinking = () => {
1448
+ if (!pendingThinking)
1449
+ return;
1450
+ if (Date.now() - thinkingAppliedAt < THINKING_MIN_DWELL_MS)
1451
+ return;
1452
+ applyThinking(pendingThinking);
1453
+ };
1454
+ const resetThinkingPacer = () => {
1455
+ thinkingAppliedAt = 0;
1456
+ pendingThinking = null;
1457
+ };
1322
1458
  const done = new Promise((resolve) => {
1323
1459
  resolveDone = resolve;
1324
1460
  });
@@ -1343,7 +1479,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1343
1479
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1344
1480
  }, 2500);
1345
1481
  };
1346
- const terminalTitle = createTerminalTitleController();
1482
+ const terminalTitle = createTerminalTitleController({
1483
+ write: (title) => bridge.setTitle(title),
1484
+ });
1347
1485
  const syncTerminalTitle = () => {
1348
1486
  const state = store.getState();
1349
1487
  terminalTitle.sync({
@@ -1356,9 +1494,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1356
1494
  return;
1357
1495
  const state = store.getState();
1358
1496
  syncTerminalTitle();
1359
- const elapsedSeconds = state.busySince
1360
- ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1361
- : 0;
1497
+ const elapsedSeconds = busyElapsedSeconds(state, Date.now());
1362
1498
  bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1363
1499
  };
1364
1500
  const remountTui = async () => {
@@ -1484,15 +1620,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1484
1620
  }
1485
1621
  };
1486
1622
  const dismissPendingApproval = () => {
1487
- if (!resolveApprovalChoice)
1623
+ if (!resolvePermissionDecision)
1488
1624
  return;
1489
- const pendingResolve = resolveApprovalChoice;
1490
- resolveApprovalChoice = null;
1491
- pendingResolve('n');
1625
+ const pendingResolve = resolvePermissionDecision;
1626
+ resolvePermissionDecision = null;
1627
+ pendingResolve({ kind: 'deny' });
1492
1628
  store.update((current) => ({
1493
- ...current,
1494
- approvalCursor: getDefaultApprovalCursor(),
1629
+ ...resumeBusyClock(current, Date.now()),
1630
+ approvalCursor: 0,
1495
1631
  approvalPrompt: null,
1632
+ approvalScrollOffset: 0,
1496
1633
  }));
1497
1634
  };
1498
1635
  const dismissPendingSudoPassword = () => {
@@ -1505,17 +1642,34 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1505
1642
  sudoPasswordBuffer = '';
1506
1643
  pendingResolve(null);
1507
1644
  store.update((current) => ({
1508
- ...current,
1645
+ ...resumeBusyClock(current, Date.now()),
1509
1646
  sudoPrompt: null,
1510
1647
  }));
1511
1648
  };
1649
+ const dismissPendingUserInput = () => {
1650
+ const pending = pendingUserInput;
1651
+ if (!pending)
1652
+ return;
1653
+ pendingUserInput = null;
1654
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1655
+ const error = new Error('Turn cancelled.');
1656
+ error.name = 'AbortError';
1657
+ pending.reject(error);
1658
+ store.update((current) => ({
1659
+ ...resumeBusyClock(current, Date.now()),
1660
+ status: current.userInputPrompt?.returnStatus ?? current.status,
1661
+ userInputPrompt: null,
1662
+ }));
1663
+ };
1512
1664
  const cancelActiveTurn = () => {
1513
- if (!store.getState().busy)
1665
+ if (!store.getState().busy || newConversationInFlight)
1514
1666
  return;
1515
1667
  disarmExitConfirm();
1516
1668
  dismissPendingApproval();
1517
1669
  dismissPendingSudoPassword();
1670
+ dismissPendingUserInput();
1518
1671
  activeTurnGeneration += 1;
1672
+ resetThinkingPacer();
1519
1673
  activeTurnAbort?.abort();
1520
1674
  activeTurnAbort = null;
1521
1675
  cancelActiveCommand();
@@ -1533,6 +1687,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1533
1687
  activeTurnInput: '',
1534
1688
  activeTurnInputPreformatted: false,
1535
1689
  busy: false,
1690
+ busyPausedAt: null,
1536
1691
  busySince: null,
1537
1692
  commandLog: [],
1538
1693
  cursor: queued ? queued.body.length : current.cursor,
@@ -1545,8 +1700,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1545
1700
  status: 'Ready',
1546
1701
  thinkingTitle: '',
1547
1702
  thinkingNotes: [],
1703
+ turnMessages: [],
1548
1704
  workingTools: [],
1549
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
1705
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
1550
1706
  }));
1551
1707
  appendStaticEntries(cancelledEntries);
1552
1708
  lastTurnStartedAt = null;
@@ -1554,14 +1710,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1554
1710
  scheduleLiveFrameRemount();
1555
1711
  void remountTui();
1556
1712
  };
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
1713
  const syncShellStateFromSession = () => {
1566
1714
  store.update((current) => ({
1567
1715
  ...current,
@@ -1640,6 +1788,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1640
1788
  }
1641
1789
  dismissPendingApproval();
1642
1790
  dismissPendingSudoPassword();
1791
+ dismissPendingUserInput();
1643
1792
  exiting = true;
1644
1793
  killAllBackgroundJobs();
1645
1794
  store.update((current) => ({
@@ -1649,9 +1798,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1649
1798
  status: 'Exiting...',
1650
1799
  }));
1651
1800
  void bridge.close().then(() => {
1801
+ releaseTerminalWrites();
1652
1802
  resolveDone?.();
1653
1803
  });
1654
1804
  };
1805
+ const exitForAuthenticationError = (error) => {
1806
+ if (!isAuthenticationError(error))
1807
+ return false;
1808
+ clearCliAuthConfig(session.env);
1809
+ if (sessionHasUserMessage(session)) {
1810
+ saveSessionState(session);
1811
+ }
1812
+ fatalError = new Error(authenticationErrorMessage(error));
1813
+ requestExit();
1814
+ return true;
1815
+ };
1816
+ const saveActiveSession = async () => {
1817
+ try {
1818
+ await saveSessionBoth({ serverSessionClient, session });
1819
+ return true;
1820
+ }
1821
+ catch (error) {
1822
+ if (exitForAuthenticationError(error))
1823
+ return false;
1824
+ appendError(`Session save failed: ${error.message}`);
1825
+ return true;
1826
+ }
1827
+ };
1828
+ const saveActiveSessionOrAbort = async () => {
1829
+ try {
1830
+ await saveSessionBoth({ serverSessionClient, session });
1831
+ return true;
1832
+ }
1833
+ catch (error) {
1834
+ if (exitForAuthenticationError(error))
1835
+ return false;
1836
+ appendError(`Session save failed: ${error.message}`);
1837
+ return false;
1838
+ }
1839
+ };
1655
1840
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1656
1841
  if (exiting || signal?.aborted) {
1657
1842
  resolve(null);
@@ -1669,7 +1854,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1669
1854
  cleanupSudoPasswordPrompt = null;
1670
1855
  }
1671
1856
  store.update((current) => ({
1672
- ...current,
1857
+ ...pauseBusyClock(current, Date.now()),
1673
1858
  sudoPrompt: {
1674
1859
  command,
1675
1860
  passwordLength: 0,
@@ -1689,7 +1874,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1689
1874
  cleanupSudoPasswordPrompt = null;
1690
1875
  sudoPasswordBuffer = '';
1691
1876
  store.update((current) => ({
1692
- ...current,
1877
+ ...resumeBusyClock(current, Date.now()),
1693
1878
  sudoPrompt: null,
1694
1879
  status: current.sudoPrompt?.returnStatus ?? current.status,
1695
1880
  }));
@@ -1703,7 +1888,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1703
1888
  cleanupSudoPasswordPrompt = null;
1704
1889
  sudoPasswordBuffer = '';
1705
1890
  store.update((current) => ({
1706
- ...current,
1891
+ ...resumeBusyClock(current, Date.now()),
1707
1892
  sudoPrompt: null,
1708
1893
  status: current.sudoPrompt?.returnStatus ?? current.status,
1709
1894
  }));
@@ -1723,38 +1908,88 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1723
1908
  : null,
1724
1909
  }));
1725
1910
  };
1726
- const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1911
+ const openUserInputPrompt = (questions, signal) => new Promise((resolve, reject) => {
1912
+ if (exiting || signal?.aborted) {
1913
+ const error = new Error('Turn cancelled.');
1914
+ error.name = 'AbortError';
1915
+ reject(error);
1916
+ return;
1917
+ }
1918
+ const onAbort = () => dismissPendingUserInput();
1919
+ pendingUserInput = {
1920
+ questions,
1921
+ resolve,
1922
+ reject,
1923
+ signal,
1924
+ onAbort,
1925
+ };
1926
+ signal?.addEventListener('abort', onAbort, { once: true });
1927
+ store.update((current) => ({
1928
+ ...pauseBusyClock(current, Date.now()),
1929
+ status: 'Waiting for your input',
1930
+ userInputPrompt: createUserInputPromptState(questions, current.status),
1931
+ }));
1932
+ scheduleLiveFrameRemount();
1933
+ });
1934
+ const handleInlineUserInput = async (result) => {
1935
+ const pending = pendingUserInput;
1936
+ const prompt = store.getState().userInputPrompt;
1937
+ if (!pending || !prompt)
1938
+ return;
1939
+ pendingUserInput = null;
1940
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1941
+ store.update((current) => ({
1942
+ ...resumeBusyClock(current, Date.now()),
1943
+ status: prompt.returnStatus,
1944
+ userInputPrompt: null,
1945
+ }));
1946
+ appendTurnAwareEntry({
1947
+ body: formatUserInputTranscript(pending.questions, result),
1948
+ kind: result.status === 'submitted' ? 'user' : 'system',
1949
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
1950
+ });
1951
+ pending.resolve(result);
1952
+ scheduleLiveFrameRemount();
1953
+ };
1954
+ const openApprovalPrompt = (request) => new Promise((resolve) => {
1955
+ const deny = { kind: 'deny' };
1727
1956
  if (exiting) {
1728
- resolve('n');
1957
+ resolve(deny);
1729
1958
  return;
1730
1959
  }
1731
- resolveApprovalChoice = resolve;
1960
+ resolvePermissionDecision = resolve;
1732
1961
  store.update((current) => ({
1733
- ...current,
1734
- approvalCursor: getDefaultApprovalCursor(),
1962
+ ...pauseBusyClock(current, Date.now()),
1963
+ approvalCursor: getDefaultApprovalCursor(request.options.length),
1964
+ approvalOpenedAt: Date.now(),
1965
+ approvalScrollOffset: 0,
1735
1966
  approvalPrompt: {
1736
- title,
1737
- body,
1738
- diffPreview: options.diff && options.filePath
1739
- ? parseDiffPreview(options.diff)
1967
+ title: request.title,
1968
+ body: request.body,
1969
+ diffPreview: request.diff && request.filePath
1970
+ ? parseDiffPreview(request.diff)
1740
1971
  : undefined,
1741
- filePath: options.filePath,
1972
+ filePath: request.filePath,
1973
+ options: request.options,
1742
1974
  returnStatus: current.status,
1743
1975
  },
1744
- status: title,
1976
+ status: request.title,
1745
1977
  }));
1746
1978
  });
1747
- const handleInlineApprovalChoice = async (choice) => {
1979
+ const handleInlineApprovalChoice = async (index) => {
1748
1980
  const current = store.getState();
1749
- const pendingResolve = resolveApprovalChoice;
1750
- resolveApprovalChoice = null;
1981
+ const options = current.approvalPrompt?.options ?? [];
1982
+ const chosen = options[index]?.decision ?? { kind: 'deny' };
1983
+ const pendingResolve = resolvePermissionDecision;
1984
+ resolvePermissionDecision = null;
1751
1985
  store.update((next) => ({
1752
- ...next,
1753
- approvalCursor: getDefaultApprovalCursor(),
1986
+ ...resumeBusyClock(next, Date.now()),
1987
+ approvalCursor: getDefaultApprovalCursor(options.length),
1754
1988
  approvalPrompt: null,
1989
+ approvalScrollOffset: 0,
1755
1990
  status: current.approvalPrompt?.returnStatus ?? next.status,
1756
1991
  }));
1757
- pendingResolve?.(choice);
1992
+ pendingResolve?.(chosen);
1758
1993
  };
1759
1994
  const refreshServerModels = async () => {
1760
1995
  currentServerModels = await models.fetchServerModels({ config: authConfig });
@@ -1788,7 +2023,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1788
2023
  selectedModelId: selected,
1789
2024
  serverModels: fresh,
1790
2025
  });
1791
- await saveActiveSession();
2026
+ if (!(await saveActiveSession()))
2027
+ return;
1792
2028
  syncShellStateFromSession();
1793
2029
  appendStaticEntry({
1794
2030
  body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
@@ -1813,7 +2049,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1813
2049
  const id = jobId.trim();
1814
2050
  await collectBackgroundJobUiOutputMutations({
1815
2051
  session,
1816
- projectIndex,
1817
2052
  jobId: id,
1818
2053
  });
1819
2054
  const job = listBackgroundJobs().find((candidate) => candidate.id === id);
@@ -1845,7 +2080,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1845
2080
  return;
1846
2081
  await collectBackgroundJobUiOutputMutations({
1847
2082
  session,
1848
- projectIndex,
1849
2083
  jobId,
1850
2084
  });
1851
2085
  syncBackgroundJobsState();
@@ -1861,7 +2095,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1861
2095
  const killed = await killBackgroundJob(jobId);
1862
2096
  await collectBackgroundJobUiKillMutations({
1863
2097
  session,
1864
- projectIndex,
1865
2098
  jobId,
1866
2099
  result: killed,
1867
2100
  });
@@ -1907,6 +2140,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1907
2140
  await switchToModel(selected.id);
1908
2141
  }
1909
2142
  catch (error) {
2143
+ if (exitForAuthenticationError(error))
2144
+ return;
1910
2145
  appendError(error.message);
1911
2146
  }
1912
2147
  };
@@ -1956,6 +2191,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1956
2191
  store.update((next) => ({
1957
2192
  ...next,
1958
2193
  busy: true,
2194
+ busyPausedAt: null,
1959
2195
  busySince: resumeStartedAt,
1960
2196
  clockNow: resumeStartedAt,
1961
2197
  status: 'Loading session...',
@@ -1965,14 +2201,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1965
2201
  applySessionSnapshot(session, snapshot);
1966
2202
  setBackgroundJobSession(session.sessionId);
1967
2203
  setScratchSession(session.sessionId);
2204
+ setImageStoreSession(session.sessionId);
1968
2205
  syncBackgroundJobsState();
1969
2206
  setTodoSession(session.sessionId);
1970
2207
  syncTodosState();
1971
- await saveActiveSession();
1972
2208
  syncShellStateFromSession();
1973
2209
  store.update((next) => ({
1974
2210
  ...next,
1975
2211
  busy: false,
2212
+ busyPausedAt: null,
1976
2213
  busySince: null,
1977
2214
  resumePickerFilter: '',
1978
2215
  resumePickerIndex: 0,
@@ -1991,9 +2228,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1991
2228
  await remountTui();
1992
2229
  }
1993
2230
  catch (error) {
2231
+ if (exitForAuthenticationError(error))
2232
+ return;
1994
2233
  store.update((next) => ({
1995
2234
  ...next,
1996
2235
  busy: false,
2236
+ busyPausedAt: null,
1997
2237
  busySince: null,
1998
2238
  resumePickerFilter: '',
1999
2239
  resumePickerIndex: 0,
@@ -2017,6 +2257,127 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2017
2257
  }));
2018
2258
  await handleSubmit(queued.body);
2019
2259
  };
2260
+ let turnMessageCounter = 0;
2261
+ const upsertTurnMessage = (id, patch) => {
2262
+ store.update((current) => ({
2263
+ ...current,
2264
+ turnMessages: current.turnMessages.map((message) => message.id === id ? { ...message, ...patch } : message),
2265
+ }));
2266
+ };
2267
+ const recoverUnacknowledgedMessages = (autoSubmits = true) => {
2268
+ if (unacknowledged.size === 0)
2269
+ return;
2270
+ const stranded = [...unacknowledged.values()];
2271
+ unacknowledged = new Map();
2272
+ for (const { row } of stranded) {
2273
+ upsertTurnMessage(row, {
2274
+ state: 'queued',
2275
+ note: autoSubmits
2276
+ ? 'not read — sending as the next prompt'
2277
+ : 'not read — left in the composer',
2278
+ });
2279
+ }
2280
+ const existing = store.getState().queuedMessage;
2281
+ const combined = [
2282
+ ...stranded.map((entry) => entry.queued),
2283
+ ...(existing ? [existing] : []),
2284
+ ];
2285
+ const body = combined
2286
+ .map((entry) => entry.body)
2287
+ .filter((entry) => entry.trim())
2288
+ .join('\n\n');
2289
+ if (!body.trim())
2290
+ return;
2291
+ scheduleLiveFrameRemount();
2292
+ store.update((current) => ({
2293
+ ...current,
2294
+ queuedMessage: {
2295
+ body,
2296
+ imageAttachments: combined.flatMap((entry) => entry.imageAttachments),
2297
+ pastedChunks: combined.flatMap((entry) => entry.pastedChunks),
2298
+ },
2299
+ }));
2300
+ };
2301
+ const fireQueuedMessage = async () => {
2302
+ const state = store.getState();
2303
+ const queued = state.queuedMessage;
2304
+ if (!queued || !state.busy || exiting)
2305
+ return;
2306
+ const text = (queued.pastedChunks.length
2307
+ ? expandPastedChunks(queued.body, queued.pastedChunks)
2308
+ : queued.body).trim();
2309
+ const images = queued.imageAttachments.filter((attachment) => queued.body.includes(`[Image #${attachment.index}]`));
2310
+ if (!text && images.length === 0)
2311
+ return;
2312
+ const turnId = activeServerTurnId;
2313
+ const blockedReason = !turnId ? 'waiting for the turn to end' : null;
2314
+ const existingRow = blockedRows.get(queued);
2315
+ if (existingRow !== undefined) {
2316
+ upsertTurnMessage(existingRow, {
2317
+ state: 'queued',
2318
+ ...(blockedReason ? { note: blockedReason } : {}),
2319
+ });
2320
+ if (blockedReason || !turnId)
2321
+ return;
2322
+ }
2323
+ const id = existingRow ?? ++turnMessageCounter;
2324
+ const messageId = `m${id}_${Date.now().toString(36)}`;
2325
+ if (existingRow === undefined) {
2326
+ scheduleLiveFrameRemount();
2327
+ store.update((current) => ({
2328
+ ...current,
2329
+ turnMessages: [
2330
+ ...current.turnMessages,
2331
+ {
2332
+ id,
2333
+ text,
2334
+ state: blockedReason ? 'queued' : 'sending',
2335
+ ...(blockedReason ? { note: blockedReason } : {}),
2336
+ },
2337
+ ],
2338
+ }));
2339
+ }
2340
+ if (blockedReason || !turnId) {
2341
+ blockedRows.set(queued, id);
2342
+ return;
2343
+ }
2344
+ blockedRows.delete(queued);
2345
+ unacknowledged.set(messageId, { row: id, queued });
2346
+ scheduleLiveFrameRemount();
2347
+ store.update((current) => current.queuedMessage === queued
2348
+ ? { ...current, queuedMessage: null }
2349
+ : current);
2350
+ queueTurnEntry({
2351
+ body: text,
2352
+ kind: 'user',
2353
+ preformatted: queued.pastedChunks.length > 0,
2354
+ title: 'You · sent mid-turn',
2355
+ });
2356
+ const rollback = (note) => {
2357
+ if (!unacknowledged.delete(messageId))
2358
+ return;
2359
+ upsertTurnMessage(id, { state: 'queued', note });
2360
+ scheduleLiveFrameRemount();
2361
+ store.update((current) => current.queuedMessage ? current : { ...current, queuedMessage: queued });
2362
+ };
2363
+ let outcome;
2364
+ try {
2365
+ outcome = await chat.postInterjection({
2366
+ config: authConfig,
2367
+ turnId,
2368
+ text,
2369
+ messageId,
2370
+ imageAttachments: images,
2371
+ });
2372
+ }
2373
+ catch (error) {
2374
+ rollback(`not sent — ${error?.message ?? 'request failed'}`);
2375
+ return;
2376
+ }
2377
+ if (outcome === 'stale') {
2378
+ rollback('turn ended — sending as the next prompt');
2379
+ }
2380
+ };
2020
2381
  const handleSubmit = async (rawInput) => {
2021
2382
  const chunks = store.getState().pastedChunks;
2022
2383
  const expanded = chunks.length
@@ -2036,9 +2397,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2036
2397
  return;
2037
2398
  }
2038
2399
  const pending = store.getState();
2400
+ const body = String(rawInput ?? '');
2039
2401
  const snapshot = {
2040
- body: String(rawInput ?? ''),
2041
- imageAttachments: pending.imageAttachments,
2402
+ body,
2403
+ imageAttachments: pending.imageAttachments.filter((attachment) => body.includes(`[Image #${attachment.index}]`)),
2042
2404
  pastedChunks: pending.pastedChunks,
2043
2405
  };
2044
2406
  scheduleLiveFrameRemount();
@@ -2053,11 +2415,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2053
2415
  }));
2054
2416
  return;
2055
2417
  }
2056
- const imageAttachments = store.getState().imageAttachments;
2418
+ const imageAttachments = store
2419
+ .getState()
2420
+ .imageAttachments.filter((attachment) => input.includes(`[Image #${attachment.index}]`));
2057
2421
  scheduleLiveFrameRemount();
2058
2422
  store.update((current) => ({
2059
2423
  ...current,
2060
2424
  cursor: 0,
2425
+ imageAttachments: [],
2061
2426
  input: '',
2062
2427
  pastedChunks: [],
2063
2428
  promptHistory: appendPromptToHistory(current.promptHistory, input),
@@ -2073,6 +2438,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2073
2438
  requestExit();
2074
2439
  return;
2075
2440
  }
2441
+ if (input === '/logout') {
2442
+ store.update((current) => ({
2443
+ ...current,
2444
+ busy: true,
2445
+ status: 'Signing out...',
2446
+ }));
2447
+ try {
2448
+ await logoutFromServer({ config: authConfig });
2449
+ }
2450
+ catch {
2451
+ }
2452
+ clearCliAuthConfig(session.env);
2453
+ signedOut = true;
2454
+ requestExit();
2455
+ return;
2456
+ }
2076
2457
  if (input === '/help') {
2077
2458
  appendStaticEntry({
2078
2459
  body: formatInteractiveHelpText(),
@@ -2089,6 +2470,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2089
2470
  });
2090
2471
  return;
2091
2472
  }
2473
+ if (input === '/report') {
2474
+ const opened = await openUrl(REPORT_ISSUE_URL);
2475
+ appendStaticEntries(buildReportTranscriptEntries(opened));
2476
+ return;
2477
+ }
2092
2478
  if (input === '/jobs' || input.startsWith('/jobs ')) {
2093
2479
  const jobsArgs = input.slice('/jobs'.length).trim();
2094
2480
  if (!jobsArgs) {
@@ -2101,7 +2487,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2101
2487
  const killed = await killBackgroundJob(jobId);
2102
2488
  await collectBackgroundJobUiKillMutations({
2103
2489
  session,
2104
- projectIndex,
2105
2490
  jobId,
2106
2491
  result: killed,
2107
2492
  });
@@ -2143,14 +2528,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2143
2528
  });
2144
2529
  }
2145
2530
  catch (error) {
2531
+ if (exitForAuthenticationError(error))
2532
+ return;
2146
2533
  appendError(error.message);
2147
2534
  }
2148
2535
  finally {
2149
- store.update((current) => ({
2150
- ...current,
2151
- busy: false,
2152
- status: 'Ready',
2153
- }));
2536
+ if (!exiting) {
2537
+ store.update((current) => ({
2538
+ ...current,
2539
+ busy: false,
2540
+ status: 'Ready',
2541
+ }));
2542
+ }
2154
2543
  }
2155
2544
  return;
2156
2545
  }
@@ -2164,14 +2553,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2164
2553
  await openResumePicker();
2165
2554
  }
2166
2555
  catch (error) {
2556
+ if (exitForAuthenticationError(error))
2557
+ return;
2167
2558
  appendError(error.message);
2168
2559
  }
2169
2560
  finally {
2170
- store.update((current) => ({
2171
- ...current,
2172
- busy: false,
2173
- status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2174
- }));
2561
+ if (!exiting) {
2562
+ store.update((current) => ({
2563
+ ...current,
2564
+ busy: false,
2565
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2566
+ }));
2567
+ }
2175
2568
  }
2176
2569
  return;
2177
2570
  }
@@ -2191,43 +2584,85 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2191
2584
  }
2192
2585
  }
2193
2586
  catch (error) {
2587
+ if (exitForAuthenticationError(error))
2588
+ return;
2194
2589
  appendError(error.message);
2195
2590
  }
2196
2591
  finally {
2592
+ if (!exiting) {
2593
+ store.update((current) => ({
2594
+ ...current,
2595
+ busy: false,
2596
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2597
+ }));
2598
+ }
2599
+ }
2600
+ return;
2601
+ }
2602
+ if (input === '/new') {
2603
+ newConversationInFlight = true;
2604
+ store.update((current) => ({
2605
+ ...current,
2606
+ busy: true,
2607
+ busyPausedAt: null,
2608
+ busySince: Date.now(),
2609
+ imageAttachments: [],
2610
+ queuedMessage: null,
2611
+ status: 'Starting a new conversation...',
2612
+ }));
2613
+ if (!(await saveActiveSessionOrAbort())) {
2614
+ newConversationInFlight = false;
2197
2615
  store.update((current) => ({
2198
2616
  ...current,
2199
2617
  busy: false,
2200
- status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2618
+ busyPausedAt: null,
2619
+ busySince: null,
2620
+ status: 'Ready',
2201
2621
  }));
2622
+ await flushQueuedMessage();
2623
+ return;
2202
2624
  }
2203
- return;
2204
- }
2205
- if (input === '/clear') {
2206
- clearConversation(session);
2625
+ startNewConversation(session);
2626
+ setBackgroundJobSession(session.sessionId);
2627
+ setScratchSession(session.sessionId);
2628
+ setImageStoreSession(session.sessionId);
2629
+ setTodoSession(session.sessionId);
2207
2630
  clearTodos();
2631
+ syncBackgroundJobsState();
2208
2632
  syncTodosState();
2209
2633
  latestUsageSummary = null;
2210
- await saveActiveSession();
2211
2634
  store.replaceTranscript([
2212
2635
  {
2213
- body: 'Conversation cleared.',
2636
+ body: 'Started a new conversation. The previous session remains saved.',
2214
2637
  kind: 'system',
2215
- title: 'System',
2638
+ title: 'Session',
2216
2639
  },
2217
2640
  ]);
2641
+ await saveActiveSession();
2218
2642
  syncShellStateFromSession();
2219
- store.update((current) => ({ ...current, queuedMessage: null }));
2643
+ newConversationInFlight = false;
2644
+ store.update((current) => ({
2645
+ ...current,
2646
+ busy: false,
2647
+ busyPausedAt: null,
2648
+ busySince: null,
2649
+ status: 'Ready',
2650
+ }));
2220
2651
  await remountTui();
2652
+ await flushQueuedMessage();
2221
2653
  return;
2222
2654
  }
2223
2655
  latestUsageSummary = null;
2224
2656
  disarmExitConfirm();
2657
+ unacknowledged = new Map();
2225
2658
  const turnStartedAt = Date.now();
2226
2659
  const turnGeneration = ++activeTurnGeneration;
2227
2660
  const turnAbort = new AbortController();
2228
2661
  activeTurnAbort = turnAbort;
2229
2662
  lastTurnStartedAt = turnStartedAt;
2230
2663
  todosTouchedThisTurn = false;
2664
+ clearTodos();
2665
+ syncTodosState();
2231
2666
  const userEntry = {
2232
2667
  body: input,
2233
2668
  kind: 'user',
@@ -2236,11 +2671,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2236
2671
  };
2237
2672
  pendingTurnEntries = [];
2238
2673
  queueTurnEntry(userEntry);
2674
+ resetThinkingPacer();
2239
2675
  store.update((current) => ({
2240
2676
  ...current,
2241
2677
  activeTurnInput: input,
2242
2678
  activeTurnInputPreformatted: preformatted,
2679
+ analyzingImages: 0,
2680
+ generatingImage: false,
2243
2681
  busy: true,
2682
+ busyPausedAt: null,
2244
2683
  busySince: turnStartedAt,
2245
2684
  clockNow: turnStartedAt,
2246
2685
  status: 'Running turn...',
@@ -2249,22 +2688,42 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2249
2688
  transcriptScrollOffset: 0,
2250
2689
  tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
2251
2690
  turnCounter: current.turnCounter + 1,
2691
+ turnMessages: [],
2252
2692
  workingTools: [],
2253
2693
  }));
2254
2694
  try {
2255
2695
  const result = await chat.sendServerUserMessage({
2256
2696
  config: authConfig,
2257
- projectIndex,
2258
2697
  session,
2259
2698
  input,
2260
2699
  imageAttachments,
2261
2700
  signal: turnAbort.signal,
2701
+ onTurnStart: (serverTurnId) => {
2702
+ if (turnGeneration !== activeTurnGeneration)
2703
+ return;
2704
+ activeServerTurnId = serverTurnId;
2705
+ },
2706
+ onInterjectionDelivered: (event) => {
2707
+ if (turnGeneration !== activeTurnGeneration)
2708
+ return;
2709
+ for (const messageId of event.messageIds ?? []) {
2710
+ const pending = unacknowledged.get(messageId);
2711
+ if (!pending)
2712
+ continue;
2713
+ unacknowledged.delete(messageId);
2714
+ upsertTurnMessage(pending.row, {
2715
+ state: 'delivered',
2716
+ note: undefined,
2717
+ });
2718
+ }
2719
+ },
2262
2720
  });
2263
2721
  if (turnGeneration !== activeTurnGeneration)
2264
2722
  return;
2265
2723
  latestUsageSummary = result.usageSummary ?? null;
2266
2724
  const turnEntries = takePendingTurnEntries();
2267
- await saveActiveSession();
2725
+ if (!(await saveActiveSession()))
2726
+ return;
2268
2727
  syncShellStateFromSession();
2269
2728
  store.update((current) => ({
2270
2729
  ...current,
@@ -2301,10 +2760,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2301
2760
  store.update((current) => ({
2302
2761
  ...current,
2303
2762
  busy: false,
2763
+ busyPausedAt: null,
2304
2764
  busySince: null,
2305
2765
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2306
2766
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2307
2767
  }));
2768
+ recoverUnacknowledgedMessages();
2308
2769
  await flushQueuedMessage();
2309
2770
  }
2310
2771
  catch (error) {
@@ -2315,11 +2776,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2315
2776
  return;
2316
2777
  }
2317
2778
  const cancelled = isTurnCancelledError(error);
2779
+ if (exitForAuthenticationError(error))
2780
+ return;
2318
2781
  store.update((current) => ({
2319
2782
  ...current,
2320
2783
  activeTurnInput: '',
2321
2784
  activeTurnInputPreformatted: false,
2322
2785
  busy: false,
2786
+ busyPausedAt: null,
2323
2787
  busySince: null,
2324
2788
  commandLog: [],
2325
2789
  imageAttachments: [],
@@ -2339,13 +2803,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2339
2803
  title: 'System',
2340
2804
  },
2341
2805
  ]);
2342
- await saveActiveSession();
2806
+ if (!(await saveActiveSession()))
2807
+ return;
2343
2808
  }
2344
2809
  else {
2345
2810
  appendStaticEntries(turnEntries);
2346
2811
  appendError(error.message);
2812
+ appendStaticEntries(buildTurnFailureHintEntries(cancelled));
2347
2813
  }
2348
2814
  await remountTui();
2815
+ recoverUnacknowledgedMessages(!cancelled);
2349
2816
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2350
2817
  await flushQueuedMessage();
2351
2818
  }
@@ -2356,24 +2823,42 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2356
2823
  }
2357
2824
  if (turnGeneration === activeTurnGeneration) {
2358
2825
  lastTurnStartedAt = null;
2826
+ activeServerTurnId = null;
2359
2827
  }
2828
+ recoverUnacknowledgedMessages();
2360
2829
  }
2361
2830
  };
2831
+ session.onImageAnalysis = (activeImageCount) => {
2832
+ store.update((current) => ({
2833
+ ...current,
2834
+ analyzingImages: Math.max(0, activeImageCount),
2835
+ generatingImage: activeImageCount > 0 ? false : current.generatingImage,
2836
+ }));
2837
+ };
2838
+ session.onImageGeneration = (active) => {
2839
+ store.update((current) => ({
2840
+ ...current,
2841
+ generatingImage: Boolean(active),
2842
+ analyzingImages: active ? 0 : current.analyzingImages,
2843
+ }));
2844
+ };
2362
2845
  session.onStatus = (message) => {
2363
2846
  const panel = thinkingPanelFromStatus(message);
2364
2847
  store.update((current) => ({
2365
2848
  ...current,
2366
2849
  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),
2850
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2370
2851
  }));
2852
+ queueThinkingUpdate({
2853
+ title: panel ? panel.title : liveStatusPanelTitle(message),
2854
+ notes: panel ? panel.notes : [],
2855
+ });
2371
2856
  };
2372
2857
  session.onContextLog = (message) => {
2373
2858
  store.update((current) => ({
2374
2859
  ...current,
2375
2860
  contextStatus: message,
2376
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2861
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2377
2862
  }));
2378
2863
  };
2379
2864
  session.onToolEvent = (event) => {
@@ -2439,54 +2924,46 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2439
2924
  appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2440
2925
  }
2441
2926
  });
2442
- projectIndex.onStatus = session.onStatus;
2443
- projectIndex.onContextLog = session.onContextLog;
2444
2927
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2445
- session.confirmCommand = async (command) => {
2928
+ session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
2929
+ session.requestPermission = async (request) => {
2930
+ const deny = { kind: 'deny' };
2446
2931
  if (exiting)
2447
- return false;
2932
+ return deny;
2448
2933
  if (sessionAutoYes)
2449
- return true;
2450
- const choice = await openApprovalPrompt('Approve command?', command);
2451
- if (choice === 'a') {
2452
- setAgentMode('auto-accept');
2453
- syncShellStateFromSession();
2934
+ return { kind: 'once' };
2935
+ const decision = await openApprovalPrompt(request);
2936
+ if (decision.kind === 'always-bucket') {
2454
2937
  appendTurnAwareEntry({
2455
- body: 'Auto-approve enabled for the rest of this session.',
2938
+ body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
2456
2939
  kind: 'system',
2457
2940
  title: 'Approvals',
2458
2941
  });
2459
- return true;
2460
2942
  }
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();
2943
+ else if (decision.kind === 'always-prefix') {
2475
2944
  appendTurnAwareEntry({
2476
- body: 'Auto-approve enabled for the rest of this session.',
2945
+ body: `Allowed for the rest of this session: commands starting with \`${decision.prefix}\`.`,
2477
2946
  kind: 'system',
2478
2947
  title: 'Approvals',
2479
2948
  });
2480
- return true;
2481
2949
  }
2482
- return choice === 'y';
2950
+ return decision;
2483
2951
  };
2484
2952
  const shellInputHandlers = {
2953
+ getApprovalScrollLimit: () => {
2954
+ const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2955
+ return approvalScrollLimit(store.getState().approvalPrompt, contentWidth, terminalRows);
2956
+ },
2485
2957
  getTranscriptScrollLimit: () => {
2486
2958
  const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2487
2959
  const blocks = store.getState().transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
2488
2960
  return blocks.reduce((total, block, index) => total + block.length + (index > 0 ? 1 : 0), 0);
2489
2961
  },
2962
+ getUserInputViewport: () => {
2963
+ const nowMs = Date.now();
2964
+ const state = store.getState();
2965
+ return userInputViewportForFrame(state, terminalCols, terminalRows, spinnerFrame, busyElapsedSeconds(state, nowMs), nowMs);
2966
+ },
2490
2967
  onCancelTurn: cancelActiveTurn,
2491
2968
  onCycleAgentMode: cycleAgentMode,
2492
2969
  onCtrlC: handleCtrlC,
@@ -2495,6 +2972,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2495
2972
  onLiveFrameShapeChange: scheduleLiveFrameRemount,
2496
2973
  onRequestExit: requestExit,
2497
2974
  onResolveApproval: handleInlineApprovalChoice,
2975
+ onResolveUserInput: handleInlineUserInput,
2498
2976
  onResumeSession: handleInlineResumeSelection,
2499
2977
  onSelectionCopy: handleAppSelectionCopy,
2500
2978
  onSelectModel: handleInlineModelSelection,
@@ -2502,6 +2980,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2502
2980
  onJobsPickerKill: handleJobsPickerKill,
2503
2981
  onSudoPasswordInput: handleSudoPasswordInput,
2504
2982
  onSubmit: handleSubmit,
2983
+ onFireQueuedMessage: fireQueuedMessage,
2505
2984
  };
2506
2985
  const unsubscribe = store.subscribe(() => {
2507
2986
  renderCurrentFrame();
@@ -2509,6 +2988,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2509
2988
  const spinnerTimer = setInterval(() => {
2510
2989
  spinnerFrame = (spinnerFrame + 1) % 6;
2511
2990
  if (store.getState().busy) {
2991
+ flushPendingThinking();
2512
2992
  renderCurrentFrame();
2513
2993
  }
2514
2994
  }, 120);
@@ -2564,10 +3044,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2564
3044
  killAllBackgroundJobs();
2565
3045
  setBackgroundJobSession(null);
2566
3046
  setScratchSession(null);
3047
+ setImageStoreSession(null);
2567
3048
  setTodoSession(null);
2568
3049
  setBackgroundJobUpdateHook(null);
2569
3050
  await bridge.close();
3051
+ releaseTerminalWrites();
2570
3052
  setCommandOutputHook(null);
2571
3053
  }
2572
3054
  });
3055
+ if (fatalError)
3056
+ throw fatalError;
3057
+ return { signedOut };
2573
3058
  }