@thegitai/cli 1.0.0-preview.4 → 1.0.0-preview.40

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 (68) hide show
  1. package/README.md +49 -4
  2. package/dist/bin/ai.js +119 -111
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +11 -6
  5. package/dist/src/api/auth.js +6 -4
  6. package/dist/src/api/browser-login.js +152 -37
  7. package/dist/src/api/chat.js +286 -44
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +127 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/background-jobs.js +4 -2
  13. package/dist/src/browser/bridge.js +232 -0
  14. package/dist/src/browser/framing.js +42 -0
  15. package/dist/src/browser/native-host.js +209 -0
  16. package/dist/src/browser/protocol.js +46 -0
  17. package/dist/src/browser/session-bridge.js +104 -0
  18. package/dist/src/cli-args.js +19 -5
  19. package/dist/src/client-environment.js +2 -0
  20. package/dist/src/core/clipboard.js +7 -13
  21. package/dist/src/core/image-limits.js +56 -0
  22. package/dist/src/core/image-path-extractor.js +70 -3
  23. package/dist/src/core/session-image-store.js +246 -0
  24. package/dist/src/edit-journal.js +33 -2
  25. package/dist/src/executor.js +25 -3
  26. package/dist/src/help-text.js +58 -17
  27. package/dist/src/patcher.js +43 -4
  28. package/dist/src/permissions.js +288 -0
  29. package/dist/src/session-safety.js +0 -12
  30. package/dist/src/session-store.js +121 -20
  31. package/dist/src/session.js +19 -4
  32. package/dist/src/signin.js +58 -0
  33. package/dist/src/tool-executor.js +12 -46
  34. package/dist/src/tools/browser.js +628 -0
  35. package/dist/src/tools/delete-file.js +20 -4
  36. package/dist/src/tools/index.js +32 -10
  37. package/dist/src/tools/patch-file.js +12 -26
  38. package/dist/src/tools/read-image-file.js +85 -0
  39. package/dist/src/tools/replace-document-text.js +28 -18
  40. package/dist/src/tools/restore-checkpoint.js +0 -1
  41. package/dist/src/tools/run-command.js +14 -71
  42. package/dist/src/tools/run-node-script.js +12 -81
  43. package/dist/src/tools/save-generated-image.js +120 -0
  44. package/dist/src/tools/str-replace.js +12 -26
  45. package/dist/src/tools/undo-edit.js +1 -6
  46. package/dist/src/tools/write-file.js +67 -11
  47. package/dist/src/ui/prompt-history-store.js +1 -1
  48. package/dist/src/ui/repl.js +802 -195
  49. package/dist/src/ui/tui/bridge.js +10 -0
  50. package/dist/src/ui/tui/build-frame.js +517 -165
  51. package/dist/src/ui/tui/composer-layout.js +76 -0
  52. package/dist/src/ui/tui/markdown-render.js +81 -73
  53. package/dist/src/ui/tui/shell-input.js +227 -67
  54. package/dist/src/ui/tui/terminal-theme.js +28 -0
  55. package/dist/src/ui/tui/terminal-title.js +3 -0
  56. package/dist/src/ui/tui/terminal-writes.js +48 -0
  57. package/dist/src/ui/tui/text.js +158 -4
  58. package/dist/src/ui/tui/user-input.js +568 -0
  59. package/dist/src/utils.js +9 -0
  60. package/package.json +20 -8
  61. package/dist/src/markdown-renderer.js +0 -112
  62. package/dist/src/project-index.js +0 -221
  63. package/dist/src/tools/code-intel.js +0 -472
  64. package/dist/src/tools/find-symbol.js +0 -70
  65. package/dist/src/tools/hover-symbol.js +0 -95
  66. package/dist/src/tools/list-symbols.js +0 -55
  67. package/dist/src/tools/search-code.js +0 -37
  68. package/dist/src/tools/signature-help.js +0 -118
@@ -1,19 +1,26 @@
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';
7
+ import { composerInputWidth, frameContentWidth } from './tui/composer-layout.js';
5
8
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
6
9
  import { chat, models } from '../api/index.js';
7
10
  import { isTurnCancelledError } from '../api/chat.js';
8
11
  import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
9
12
  import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
10
13
  import { setScratchSession } from '../scratch-dir.js';
14
+ import { setImageStoreSession } from '../core/session-image-store.js';
11
15
  import { cancelActiveCommand } from '../executor.js';
12
16
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
17
+ import { clearCliAuthConfig, logoutFromServer, } from '../api/auth.js';
18
+ import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
19
+ import { createUserInputPromptState, formatUserInputTranscript, } from './tui/user-input.js';
13
20
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
14
21
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
15
- import { clearConversation, } from '../session.js';
16
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
22
+ import { startNewConversation, } from '../session.js';
23
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../session-store.js';
17
24
  import { truncate } from '../utils.js';
18
25
  import { writeClipboardText } from '../core/clipboard.js';
19
26
  import { openUrl } from '../core/open-url.js';
@@ -70,23 +77,35 @@ const WORKING_TOOL_PREVIEW_ITEMS = 4;
70
77
  const THINKING_NOTE_PREVIEW_ROWS = 3;
71
78
  const WORKING_TOOL_PREVIEW_ROWS = 3;
72
79
  const AGENT_MODE_LABEL_WIDTH = 16;
73
- const APPROVAL_OPTIONS = [
74
- {
75
- value: 'y',
76
- label: 'Approve once',
77
- description: 'Run this action only this time',
78
- },
79
- {
80
- value: 'a',
81
- label: 'Approve all remaining actions',
82
- description: 'Turn on auto-approve for the rest of the session',
83
- },
84
- {
85
- value: 'n',
86
- label: 'Deny',
87
- description: 'Reject this action',
88
- },
89
- ];
80
+ export const REPORT_ISSUE_URL = 'https://github.com/thegitai/thegitai-cli/issues/new';
81
+ export function buildReportTranscriptEntries(openedUrl) {
82
+ return openedUrl
83
+ ? [
84
+ {
85
+ 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.`,
86
+ kind: 'system',
87
+ title: 'Report',
88
+ },
89
+ ]
90
+ : [
91
+ {
92
+ body: `Could not open a browser. Report the bug manually at:\n\n${REPORT_ISSUE_URL}\n`,
93
+ kind: 'error',
94
+ title: 'Report',
95
+ },
96
+ ];
97
+ }
98
+ export function buildTurnFailureHintEntries(cancelled) {
99
+ if (cancelled)
100
+ return [];
101
+ return [
102
+ {
103
+ body: 'Something went wrong? Use /report to submit the issue.',
104
+ kind: 'error',
105
+ title: 'Report',
106
+ },
107
+ ];
108
+ }
90
109
  export const SLASH_COMMANDS = [
91
110
  {
92
111
  command: '/help',
@@ -96,6 +115,10 @@ export const SLASH_COMMANDS = [
96
115
  command: '/about',
97
116
  description: 'Show version and platform info',
98
117
  },
118
+ {
119
+ command: '/report',
120
+ description: 'Report a bug: opens the public GitHub issue form',
121
+ },
99
122
  {
100
123
  command: '/usage',
101
124
  description: 'Show account usage percentage and reset times',
@@ -113,14 +136,29 @@ export const SLASH_COMMANDS = [
113
136
  description: 'Background jobs: pick to view output or kill',
114
137
  },
115
138
  {
116
- command: '/clear',
117
- description: 'Clear conversation history',
139
+ command: '/new',
140
+ description: 'start a new conversation; this session remains saved',
141
+ },
142
+ {
143
+ command: '/logout',
144
+ description: 'Sign out and quit',
118
145
  },
119
146
  {
120
147
  command: '/exit',
121
148
  description: 'Quit the current session',
122
149
  },
123
150
  ];
151
+ export function composerAttachmentsAfterCancel(queuedAttachments, inFlightAttachments) {
152
+ const seen = new Set();
153
+ const kept = [];
154
+ for (const attachment of [...queuedAttachments, ...inFlightAttachments]) {
155
+ if (seen.has(attachment.index))
156
+ continue;
157
+ seen.add(attachment.index);
158
+ kept.push(attachment);
159
+ }
160
+ return kept;
161
+ }
124
162
  function getShellWidth(columns) {
125
163
  const safeColumns = Math.max(columns, 20);
126
164
  const targetWidth = Math.floor(safeColumns * TUI_WIDTH_RATIO);
@@ -489,11 +527,43 @@ function buildWriteFileDiff(content) {
489
527
  return '';
490
528
  return `@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}`).join('\n')}`;
491
529
  }
530
+ const MAX_DELETE_PREVIEW_LINES = 400;
492
531
  function buildDeleteFileDiff(content) {
493
532
  const lines = splitDiffLines(String(content ?? ''));
494
- if (lines.length === 0)
533
+ if (lines.length === 0) {
534
+ return { removed: 0, text: '' };
535
+ }
536
+ const shown = lines.slice(0, MAX_DELETE_PREVIEW_LINES);
537
+ const rows = [
538
+ `@@ -1,${lines.length} +0,0 @@`,
539
+ ...shown.map((line) => `-${line}`),
540
+ ];
541
+ const omitted = lines.length - shown.length;
542
+ if (omitted > 0) {
543
+ rows.push(`@@ ${omitted} more removed line(s) not shown @@`);
544
+ }
545
+ return { removed: lines.length, text: rows.join('\n') };
546
+ }
547
+ function deletedFileSummary(result) {
548
+ const bytes = typeof result?.bytes === 'number' ? result.bytes : null;
549
+ if (bytes === null) {
495
550
  return '';
496
- return `@@ -1,${lines.length} +0,0 @@\n${lines.map((line) => `-${line}`).join('\n')}`;
551
+ }
552
+ const reason = result?.contentOmitted === 'binary' ? ', binary' : '';
553
+ return ` (${formatFileSize(bytes)}${reason})`;
554
+ }
555
+ function formatFileSize(bytes) {
556
+ if (bytes < 1024) {
557
+ return `${bytes} B`;
558
+ }
559
+ const units = ['KiB', 'MiB', 'GiB', 'TiB'];
560
+ let value = bytes / 1024;
561
+ let unit = 0;
562
+ while (value >= 1024 && unit < units.length - 1) {
563
+ value /= 1024;
564
+ unit += 1;
565
+ }
566
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
497
567
  }
498
568
  function isFileChangeTool(name) {
499
569
  return (name === 'patch_file' ||
@@ -515,6 +585,8 @@ function formatToolResultState(result) {
515
585
  return 'Blocked';
516
586
  if (result?.ok === true)
517
587
  return 'OK';
588
+ if (Number.isInteger(result?.httpStatus))
589
+ return String(result.httpStatus);
518
590
  return 'Failed';
519
591
  }
520
592
  function formatRunCommandResultState(result) {
@@ -619,9 +691,11 @@ function buildFileChangeEntry(event) {
619
691
  };
620
692
  }
621
693
  const content = typeof result?.content === 'string' ? result.content : '';
622
- const diffText = buildDeleteFileDiff(content);
623
- const preview = diffText ? parseDiffPreview(diffText) : undefined;
624
- const summary = preview ? ` (+0 -${preview.removed})` : '';
694
+ const diff = buildDeleteFileDiff(content);
695
+ const preview = diff.text ? parseDiffPreview(diff.text) : undefined;
696
+ const summary = preview
697
+ ? ` (+0 -${diff.removed})`
698
+ : deletedFileSummary(result);
625
699
  return {
626
700
  body: '',
627
701
  diffPreview: preview,
@@ -671,7 +745,7 @@ function buildFileChangeEntry(event) {
671
745
  title: `Edited ${filePath}${summary}`,
672
746
  };
673
747
  }
674
- function buildWorkingToolEntry(event) {
748
+ export function buildWorkingToolEntry(event) {
675
749
  const { call, result } = event;
676
750
  const error = typeof result?.error === 'string' && result.error.trim()
677
751
  ? `\n${truncate(result.error.trim(), 180)}`
@@ -784,10 +858,18 @@ function displayUserTextFromHistoryEntry(entry) {
784
858
  export function buildTranscriptFromSessionHistory(history) {
785
859
  const entries = [];
786
860
  const pendingCalls = new Map();
861
+ const pendingUserInputRequests = new Map();
787
862
  for (const entry of history) {
788
863
  if (!entry?.parts?.length)
789
864
  continue;
790
865
  for (const part of entry.parts) {
866
+ const userInputRequest = part?.userInputRequest;
867
+ if (userInputRequest && typeof userInputRequest === 'object') {
868
+ const requestId = String(userInputRequest.requestId ?? '').trim();
869
+ if (requestId && Array.isArray(userInputRequest.questions)) {
870
+ pendingUserInputRequests.set(requestId, userInputRequest.questions);
871
+ }
872
+ }
791
873
  const call = part?.functionCall;
792
874
  if (!call || typeof call !== 'object')
793
875
  continue;
@@ -797,6 +879,21 @@ export function buildTranscriptFromSessionHistory(history) {
797
879
  pendingCalls.set(callId, call);
798
880
  }
799
881
  for (const part of entry.parts) {
882
+ const userInputResult = part?.userInputResult;
883
+ if (userInputResult && typeof userInputResult === 'object') {
884
+ const requestId = String(userInputResult.requestId ?? '').trim();
885
+ const questions = pendingUserInputRequests.get(requestId);
886
+ const result = userInputResult.result;
887
+ if (questions &&
888
+ (result?.status === 'submitted' || result?.status === 'cancelled')) {
889
+ pendingUserInputRequests.delete(requestId);
890
+ entries.push({
891
+ body: formatUserInputTranscript(questions, result),
892
+ kind: result.status === 'submitted' ? 'user' : 'system',
893
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
894
+ });
895
+ }
896
+ }
800
897
  const functionResponse = part?.functionResponse;
801
898
  if (!functionResponse || typeof functionResponse !== 'object')
802
899
  continue;
@@ -825,6 +922,13 @@ export function buildTranscriptFromSessionHistory(history) {
825
922
  }
826
923
  continue;
827
924
  }
925
+ if (entry.role === 'user' && entry.kind === 'userInterjection') {
926
+ const text = String(entry.userInput ?? '').trim();
927
+ if (text) {
928
+ entries.push({ body: text, kind: 'user', title: 'You · sent mid-turn' });
929
+ }
930
+ continue;
931
+ }
828
932
  const text = textFromHistoryEntry(entry);
829
933
  if ((entry.role === 'model' || entry.role === 'assistant') && text) {
830
934
  if (isTurnFailureMarker(text)) {
@@ -853,6 +957,23 @@ function createSessionTranscript(session) {
853
957
  const transcript = buildTranscriptFromSessionHistory(session.history);
854
958
  return transcript.length ? transcript : createInitialTranscript();
855
959
  }
960
+ export function isJobsPickerCommand(input) {
961
+ return String(input ?? '').trim() === '/jobs';
962
+ }
963
+ export function statusBehindJobsPicker(state) {
964
+ return state.jobsPickerOpen
965
+ ? (state.jobsPickerReturnStatus ?? 'Ready')
966
+ : state.status;
967
+ }
968
+ export function closedJobsPickerFields(state) {
969
+ if (!state.jobsPickerOpen)
970
+ return {};
971
+ return {
972
+ jobsPickerExpandedId: null,
973
+ jobsPickerOpen: false,
974
+ jobsPickerReturnStatus: null,
975
+ };
976
+ }
856
977
  function createShellStore(initialState) {
857
978
  let state = initialState;
858
979
  let nextEntryId = 1;
@@ -960,11 +1081,15 @@ function createInitialShellState(session, serverModels, debugUi) {
960
1081
  activeTurnInput: '',
961
1082
  activeTurnInputPreformatted: false,
962
1083
  agentMode: session.agentMode,
963
- approvalCursor: getDefaultApprovalCursor(),
1084
+ analyzingImages: 0,
1085
+ generatingImage: false,
1086
+ approvalCursor: 0,
964
1087
  approvalPrompt: null,
1088
+ approvalScrollOffset: 0,
965
1089
  autoYes: session.autoYes,
966
1090
  backgroundJobs: [],
967
1091
  busy: false,
1092
+ busyPausedAt: null,
968
1093
  busySince: null,
969
1094
  clockNow: Date.now(),
970
1095
  commandCursor: 0,
@@ -992,6 +1117,7 @@ function createInitialShellState(session, serverModels, debugUi) {
992
1117
  resumePickerSessions: [],
993
1118
  transcriptScrollOffset: 0,
994
1119
  queuedMessage: null,
1120
+ turnMessages: [],
995
1121
  serverModels: serverModels.models,
996
1122
  sudoPrompt: null,
997
1123
  status: 'Ready',
@@ -1002,6 +1128,7 @@ function createInitialShellState(session, serverModels, debugUi) {
1002
1128
  tokenUsage: formatClientTokenUsage(null),
1003
1129
  transcript: [],
1004
1130
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
1131
+ userInputPrompt: null,
1005
1132
  pastedChunks: [],
1006
1133
  imageAttachments: [],
1007
1134
  workingTools: [],
@@ -1018,7 +1145,6 @@ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
1018
1145
  const reasoningTokens = usageSummary?.reasoningTokens ?? 0;
1019
1146
  const cacheTokens = usageSummary?.cacheTokens ?? 0;
1020
1147
  const cacheWriteTokens = usageSummary?.cacheWriteTokens ?? 0;
1021
- const indexTokens = usageSummary?.indexTokens ?? 0;
1022
1148
  return [
1023
1149
  'Session tokens',
1024
1150
  `in ${formatTokenCount(inputTokens)}`,
@@ -1026,7 +1152,6 @@ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
1026
1152
  ...(reasoningTokens > 0 ? [`think ${formatTokenCount(reasoningTokens)}`] : []),
1027
1153
  `cache ${formatTokenCount(cacheTokens)}`,
1028
1154
  `write ${formatTokenCount(cacheWriteTokens)}`,
1029
- `index ${formatTokenCount(indexTokens)}`,
1030
1155
  ].join(' • ');
1031
1156
  }
1032
1157
  export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
@@ -1069,6 +1194,24 @@ function splitThinkingLines(text) {
1069
1194
  .map((part) => part.trim())
1070
1195
  .filter(Boolean));
1071
1196
  }
1197
+ const LIVE_STATUS_PANEL_MAX_CHARS = 72;
1198
+ const TOOL_PROGRESS_STATUS_PATTERN = /^(?:Tool:\s|Running\s\S+(?:\slocally)?\.\.\.$)/i;
1199
+ export function isToolProgressStatus(status) {
1200
+ return TOOL_PROGRESS_STATUS_PATTERN.test(String(status ?? '').trim());
1201
+ }
1202
+ function holdOrPickFallback(currentTitle) {
1203
+ return THINKING_FALLBACK_PHRASES.includes(currentTitle.trim())
1204
+ ? currentTitle
1205
+ : pickThinkingFallbackPhrase();
1206
+ }
1207
+ function liveStatusPanelTitle(status) {
1208
+ const text = String(status ?? '').trim();
1209
+ if (!text || text.includes('\n'))
1210
+ return '';
1211
+ if (isToolProgressStatus(text))
1212
+ return '';
1213
+ return text.length <= LIVE_STATUS_PANEL_MAX_CHARS ? text : '';
1214
+ }
1072
1215
  function thinkingPanelFromStatus(status) {
1073
1216
  const rawText = thinkingNoteFromStatus(status);
1074
1217
  if (!rawText)
@@ -1092,7 +1235,7 @@ function thinkingPanelFromStatus(status) {
1092
1235
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
1093
1236
  return {
1094
1237
  title,
1095
- notes: splitThinkingLines(bodyLines.join('\n')).slice(-3),
1238
+ notes: splitThinkingLines(bodyLines.join('\n')).slice(0, 3),
1096
1239
  };
1097
1240
  }
1098
1241
  export const EXIT_CTRL_C_CONFIRM_MESSAGE = 'Press Ctrl+C again to quit.';
@@ -1222,26 +1365,37 @@ export function navigatePromptHistory(state, direction) {
1222
1365
  promptHistoryCursor: nextCursor,
1223
1366
  };
1224
1367
  }
1225
- function getDefaultApprovalCursor() {
1226
- const denyIndex = APPROVAL_OPTIONS.findIndex((option) => option.value === 'n');
1227
- return denyIndex === -1 ? 0 : denyIndex;
1228
- }
1229
- export function getNextApprovalCursor(currentIndex, direction) {
1230
- return (currentIndex + direction + APPROVAL_OPTIONS.length) % APPROVAL_OPTIONS.length;
1231
- }
1232
- export function getApprovalChoiceForCursor(cursor) {
1233
- return (APPROVAL_OPTIONS[Math.min(Math.max(cursor, 0), APPROVAL_OPTIONS.length - 1)]
1234
- ?.value ?? 'n');
1235
- }
1236
- export function resolveApprovalChoiceFromInput(input) {
1237
- const normalizedInput = String(input ?? '').trim().toLowerCase();
1238
- if (normalizedInput === 'y')
1239
- return 'y';
1240
- if (normalizedInput === 'a')
1241
- return 'a';
1242
- if (normalizedInput === 'n')
1243
- return 'n';
1244
- return null;
1368
+ export function getDefaultApprovalCursor(optionCount) {
1369
+ return Math.max(0, optionCount - 1);
1370
+ }
1371
+ export function getNextApprovalCursor(currentIndex, direction, optionCount) {
1372
+ if (optionCount <= 0)
1373
+ return 0;
1374
+ return (currentIndex + direction + optionCount) % optionCount;
1375
+ }
1376
+ export function pauseBusyClock(state, nowMs) {
1377
+ if (state.busySince === null || state.busyPausedAt !== null)
1378
+ return state;
1379
+ return { ...state, busyPausedAt: nowMs };
1380
+ }
1381
+ export function resumeBusyClock(state, nowMs) {
1382
+ if (state.busyPausedAt === null)
1383
+ return state;
1384
+ return {
1385
+ ...state,
1386
+ busyPausedAt: null,
1387
+ busySince: state.busySince === null
1388
+ ? null
1389
+ : state.busySince + Math.max(0, nowMs - state.busyPausedAt),
1390
+ };
1391
+ }
1392
+ export function busyElapsedMs(state, nowMs) {
1393
+ if (state.busySince === null)
1394
+ return null;
1395
+ return Math.max(0, (state.busyPausedAt ?? nowMs) - state.busySince);
1396
+ }
1397
+ export function busyElapsedSeconds(state, nowMs) {
1398
+ return Math.floor((busyElapsedMs(state, nowMs) ?? 0) / 1000);
1245
1399
  }
1246
1400
  export function buildModelPickerOptions(currentModelId, serverModels) {
1247
1401
  return serverModels.map((model) => ({
@@ -1279,30 +1433,37 @@ function appendCommandLog(state, text) {
1279
1433
  };
1280
1434
  }
1281
1435
  async function saveSessionBoth({ serverSessionClient, session, }) {
1436
+ if (!sessionHasUserMessage(session))
1437
+ return;
1282
1438
  saveSessionState(session);
1283
1439
  await serverSessionClient.save(session);
1284
1440
  }
1285
- export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, projectIndex, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1441
+ export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1286
1442
  if (process.stdin.isTTY !== true) {
1287
1443
  throw new Error('stdin is not a terminal');
1288
1444
  }
1289
1445
  if (process.stdout.isTTY !== true) {
1290
1446
  throw new Error('stdout is not a terminal');
1291
1447
  }
1448
+ let fatalError = null;
1449
+ let signedOut = false;
1292
1450
  await withTuiMode(async () => {
1293
1451
  setBackgroundJobSession(session.sessionId);
1294
1452
  setScratchSession(session.sessionId);
1453
+ setImageStoreSession(session.sessionId);
1295
1454
  setTodoSession(session.sessionId);
1296
1455
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1297
1456
  store.replaceTranscript(createSessionTranscript(session));
1298
1457
  let currentServerModels = serverModels;
1299
1458
  let sessionAutoYes = session.autoYes;
1300
1459
  let resolveDone = null;
1301
- let resolveApprovalChoice = null;
1460
+ let resolvePermissionDecision = null;
1302
1461
  let resolveSudoPassword = null;
1462
+ let pendingUserInput = null;
1303
1463
  let cleanupSudoPasswordPrompt = null;
1304
1464
  let sudoPasswordBuffer = '';
1305
1465
  const bridge = createRatatuiBridge();
1466
+ captureTerminalWrites();
1306
1467
  const { handleShellKeyEvent } = await import('./tui/shell-input.js');
1307
1468
  let terminalCols = 80;
1308
1469
  let terminalRows = 24;
@@ -1315,6 +1476,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1315
1476
  let latestUsageSummary = null;
1316
1477
  let pendingTurnEntries = [];
1317
1478
  let activeTurnAbort = null;
1479
+ let activeTurnImageAttachments = [];
1480
+ let activeServerTurnId = null;
1481
+ let unacknowledged = new Map();
1482
+ const blockedRows = new WeakMap();
1483
+ let newConversationInFlight = false;
1318
1484
  let todosTouchedThisTurn = false;
1319
1485
  const syncTodosState = () => {
1320
1486
  store.update((current) => ({ ...current, todos: listTodos() }));
@@ -1323,6 +1489,36 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1323
1489
  let exitCtrlCArmed = false;
1324
1490
  let exitCtrlCTimer = null;
1325
1491
  let transientStatusTimer = null;
1492
+ const THINKING_MIN_DWELL_MS = 3_000;
1493
+ let thinkingAppliedAt = 0;
1494
+ let pendingThinking = null;
1495
+ const applyThinking = (next) => {
1496
+ thinkingAppliedAt = Date.now();
1497
+ pendingThinking = null;
1498
+ store.update((current) => ({
1499
+ ...current,
1500
+ thinkingTitle: next.title || holdOrPickFallback(current.thinkingTitle),
1501
+ thinkingNotes: next.notes,
1502
+ }));
1503
+ };
1504
+ const queueThinkingUpdate = (next) => {
1505
+ if (Date.now() - thinkingAppliedAt >= THINKING_MIN_DWELL_MS) {
1506
+ applyThinking(next);
1507
+ return;
1508
+ }
1509
+ pendingThinking = next;
1510
+ };
1511
+ const flushPendingThinking = () => {
1512
+ if (!pendingThinking)
1513
+ return;
1514
+ if (Date.now() - thinkingAppliedAt < THINKING_MIN_DWELL_MS)
1515
+ return;
1516
+ applyThinking(pendingThinking);
1517
+ };
1518
+ const resetThinkingPacer = () => {
1519
+ thinkingAppliedAt = 0;
1520
+ pendingThinking = null;
1521
+ };
1326
1522
  const done = new Promise((resolve) => {
1327
1523
  resolveDone = resolve;
1328
1524
  });
@@ -1347,7 +1543,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1347
1543
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1348
1544
  }, 2500);
1349
1545
  };
1350
- const terminalTitle = createTerminalTitleController();
1546
+ const terminalTitle = createTerminalTitleController({
1547
+ write: (title) => bridge.setTitle(title),
1548
+ });
1351
1549
  const syncTerminalTitle = () => {
1352
1550
  const state = store.getState();
1353
1551
  terminalTitle.sync({
@@ -1360,9 +1558,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1360
1558
  return;
1361
1559
  const state = store.getState();
1362
1560
  syncTerminalTitle();
1363
- const elapsedSeconds = state.busySince
1364
- ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1365
- : 0;
1561
+ const elapsedSeconds = busyElapsedSeconds(state, Date.now());
1366
1562
  bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1367
1563
  };
1368
1564
  const remountTui = async () => {
@@ -1488,15 +1684,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1488
1684
  }
1489
1685
  };
1490
1686
  const dismissPendingApproval = () => {
1491
- if (!resolveApprovalChoice)
1687
+ if (!resolvePermissionDecision)
1492
1688
  return;
1493
- const pendingResolve = resolveApprovalChoice;
1494
- resolveApprovalChoice = null;
1495
- pendingResolve('n');
1689
+ const pendingResolve = resolvePermissionDecision;
1690
+ resolvePermissionDecision = null;
1691
+ pendingResolve({ kind: 'deny' });
1496
1692
  store.update((current) => ({
1497
- ...current,
1498
- approvalCursor: getDefaultApprovalCursor(),
1693
+ ...resumeBusyClock(current, Date.now()),
1694
+ approvalCursor: 0,
1499
1695
  approvalPrompt: null,
1696
+ approvalScrollOffset: 0,
1500
1697
  }));
1501
1698
  };
1502
1699
  const dismissPendingSudoPassword = () => {
@@ -1509,21 +1706,40 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1509
1706
  sudoPasswordBuffer = '';
1510
1707
  pendingResolve(null);
1511
1708
  store.update((current) => ({
1512
- ...current,
1709
+ ...resumeBusyClock(current, Date.now()),
1513
1710
  sudoPrompt: null,
1514
1711
  }));
1515
1712
  };
1713
+ const dismissPendingUserInput = () => {
1714
+ const pending = pendingUserInput;
1715
+ if (!pending)
1716
+ return;
1717
+ pendingUserInput = null;
1718
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1719
+ const error = new Error('Turn cancelled.');
1720
+ error.name = 'AbortError';
1721
+ pending.reject(error);
1722
+ store.update((current) => ({
1723
+ ...resumeBusyClock(current, Date.now()),
1724
+ status: current.userInputPrompt?.returnStatus ?? current.status,
1725
+ userInputPrompt: null,
1726
+ }));
1727
+ };
1516
1728
  const cancelActiveTurn = () => {
1517
- if (!store.getState().busy)
1729
+ if (!store.getState().busy || newConversationInFlight)
1518
1730
  return;
1519
1731
  disarmExitConfirm();
1520
1732
  dismissPendingApproval();
1521
1733
  dismissPendingSudoPassword();
1734
+ dismissPendingUserInput();
1522
1735
  activeTurnGeneration += 1;
1736
+ resetThinkingPacer();
1523
1737
  activeTurnAbort?.abort();
1524
1738
  activeTurnAbort = null;
1525
1739
  cancelActiveCommand();
1526
1740
  const queued = store.getState().queuedMessage;
1741
+ const restoredAttachments = composerAttachmentsAfterCancel(queued ? queued.imageAttachments : [], activeTurnImageAttachments);
1742
+ activeTurnImageAttachments = [];
1527
1743
  const cancelledEntries = [
1528
1744
  ...takePendingTurnEntries(),
1529
1745
  {
@@ -1537,10 +1753,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1537
1753
  activeTurnInput: '',
1538
1754
  activeTurnInputPreformatted: false,
1539
1755
  busy: false,
1756
+ busyPausedAt: null,
1540
1757
  busySince: null,
1541
1758
  commandLog: [],
1542
1759
  cursor: queued ? queued.body.length : current.cursor,
1543
- imageAttachments: queued ? queued.imageAttachments : [],
1760
+ imageAttachments: restoredAttachments,
1544
1761
  input: queued ? queued.body : current.input,
1545
1762
  pastedChunks: queued ? queued.pastedChunks : current.pastedChunks,
1546
1763
  promptHistoryCursor: queued ? null : current.promptHistoryCursor,
@@ -1549,8 +1766,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1549
1766
  status: 'Ready',
1550
1767
  thinkingTitle: '',
1551
1768
  thinkingNotes: [],
1769
+ turnMessages: [],
1552
1770
  workingTools: [],
1553
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
1771
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
1554
1772
  }));
1555
1773
  appendStaticEntries(cancelledEntries);
1556
1774
  lastTurnStartedAt = null;
@@ -1558,14 +1776,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1558
1776
  scheduleLiveFrameRemount();
1559
1777
  void remountTui();
1560
1778
  };
1561
- const saveActiveSession = async () => {
1562
- try {
1563
- await saveSessionBoth({ serverSessionClient, session });
1564
- }
1565
- catch (error) {
1566
- appendError(`Session save failed: ${error.message}`);
1567
- }
1568
- };
1569
1779
  const syncShellStateFromSession = () => {
1570
1780
  store.update((current) => ({
1571
1781
  ...current,
@@ -1644,6 +1854,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1644
1854
  }
1645
1855
  dismissPendingApproval();
1646
1856
  dismissPendingSudoPassword();
1857
+ dismissPendingUserInput();
1647
1858
  exiting = true;
1648
1859
  killAllBackgroundJobs();
1649
1860
  store.update((current) => ({
@@ -1653,9 +1864,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1653
1864
  status: 'Exiting...',
1654
1865
  }));
1655
1866
  void bridge.close().then(() => {
1867
+ releaseTerminalWrites();
1656
1868
  resolveDone?.();
1657
1869
  });
1658
1870
  };
1871
+ const exitForAuthenticationError = (error) => {
1872
+ if (!isAuthenticationError(error))
1873
+ return false;
1874
+ clearCliAuthConfig(session.env);
1875
+ if (sessionHasUserMessage(session)) {
1876
+ saveSessionState(session);
1877
+ }
1878
+ fatalError = new Error(authenticationErrorMessage(error));
1879
+ requestExit();
1880
+ return true;
1881
+ };
1882
+ const saveActiveSession = async () => {
1883
+ try {
1884
+ await saveSessionBoth({ serverSessionClient, session });
1885
+ return true;
1886
+ }
1887
+ catch (error) {
1888
+ if (exitForAuthenticationError(error))
1889
+ return false;
1890
+ appendError(`Session save failed: ${error.message}`);
1891
+ return true;
1892
+ }
1893
+ };
1894
+ const saveActiveSessionOrAbort = async () => {
1895
+ try {
1896
+ await saveSessionBoth({ serverSessionClient, session });
1897
+ return true;
1898
+ }
1899
+ catch (error) {
1900
+ if (exitForAuthenticationError(error))
1901
+ return false;
1902
+ appendError(`Session save failed: ${error.message}`);
1903
+ return false;
1904
+ }
1905
+ };
1659
1906
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1660
1907
  if (exiting || signal?.aborted) {
1661
1908
  resolve(null);
@@ -1673,7 +1920,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1673
1920
  cleanupSudoPasswordPrompt = null;
1674
1921
  }
1675
1922
  store.update((current) => ({
1676
- ...current,
1923
+ ...pauseBusyClock(current, Date.now()),
1677
1924
  sudoPrompt: {
1678
1925
  command,
1679
1926
  passwordLength: 0,
@@ -1693,7 +1940,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1693
1940
  cleanupSudoPasswordPrompt = null;
1694
1941
  sudoPasswordBuffer = '';
1695
1942
  store.update((current) => ({
1696
- ...current,
1943
+ ...resumeBusyClock(current, Date.now()),
1697
1944
  sudoPrompt: null,
1698
1945
  status: current.sudoPrompt?.returnStatus ?? current.status,
1699
1946
  }));
@@ -1707,7 +1954,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1707
1954
  cleanupSudoPasswordPrompt = null;
1708
1955
  sudoPasswordBuffer = '';
1709
1956
  store.update((current) => ({
1710
- ...current,
1957
+ ...resumeBusyClock(current, Date.now()),
1711
1958
  sudoPrompt: null,
1712
1959
  status: current.sudoPrompt?.returnStatus ?? current.status,
1713
1960
  }));
@@ -1727,38 +1974,90 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1727
1974
  : null,
1728
1975
  }));
1729
1976
  };
1730
- const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1977
+ const openUserInputPrompt = (questions, signal) => new Promise((resolve, reject) => {
1978
+ if (exiting || signal?.aborted) {
1979
+ const error = new Error('Turn cancelled.');
1980
+ error.name = 'AbortError';
1981
+ reject(error);
1982
+ return;
1983
+ }
1984
+ const onAbort = () => dismissPendingUserInput();
1985
+ pendingUserInput = {
1986
+ questions,
1987
+ resolve,
1988
+ reject,
1989
+ signal,
1990
+ onAbort,
1991
+ };
1992
+ signal?.addEventListener('abort', onAbort, { once: true });
1993
+ store.update((current) => ({
1994
+ ...pauseBusyClock(current, Date.now()),
1995
+ ...closedJobsPickerFields(current),
1996
+ status: 'Waiting for your input',
1997
+ userInputPrompt: createUserInputPromptState(questions, statusBehindJobsPicker(current)),
1998
+ }));
1999
+ scheduleLiveFrameRemount();
2000
+ });
2001
+ const handleInlineUserInput = async (result) => {
2002
+ const pending = pendingUserInput;
2003
+ const prompt = store.getState().userInputPrompt;
2004
+ if (!pending || !prompt)
2005
+ return;
2006
+ pendingUserInput = null;
2007
+ pending.signal?.removeEventListener('abort', pending.onAbort);
2008
+ store.update((current) => ({
2009
+ ...resumeBusyClock(current, Date.now()),
2010
+ status: prompt.returnStatus,
2011
+ userInputPrompt: null,
2012
+ }));
2013
+ appendTurnAwareEntry({
2014
+ body: formatUserInputTranscript(pending.questions, result),
2015
+ kind: result.status === 'submitted' ? 'user' : 'system',
2016
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
2017
+ });
2018
+ pending.resolve(result);
2019
+ scheduleLiveFrameRemount();
2020
+ };
2021
+ const openApprovalPrompt = (request) => new Promise((resolve) => {
2022
+ const deny = { kind: 'deny' };
1731
2023
  if (exiting) {
1732
- resolve('n');
2024
+ resolve(deny);
1733
2025
  return;
1734
2026
  }
1735
- resolveApprovalChoice = resolve;
2027
+ resolvePermissionDecision = resolve;
1736
2028
  store.update((current) => ({
1737
- ...current,
1738
- approvalCursor: getDefaultApprovalCursor(),
2029
+ ...pauseBusyClock(current, Date.now()),
2030
+ ...closedJobsPickerFields(current),
2031
+ approvalCursor: getDefaultApprovalCursor(request.options.length),
2032
+ approvalOpenedAt: Date.now(),
2033
+ approvalScrollOffset: 0,
1739
2034
  approvalPrompt: {
1740
- title,
1741
- body,
1742
- diffPreview: options.diff && options.filePath
1743
- ? parseDiffPreview(options.diff)
2035
+ title: request.title,
2036
+ body: request.body,
2037
+ diffPreview: request.diff && request.filePath
2038
+ ? parseDiffPreview(request.diff)
1744
2039
  : undefined,
1745
- filePath: options.filePath,
1746
- returnStatus: current.status,
2040
+ filePath: request.filePath,
2041
+ options: request.options,
2042
+ returnStatus: statusBehindJobsPicker(current),
1747
2043
  },
1748
- status: title,
2044
+ status: request.title,
1749
2045
  }));
1750
2046
  });
1751
- const handleInlineApprovalChoice = async (choice) => {
2047
+ const handleInlineApprovalChoice = async (index) => {
1752
2048
  const current = store.getState();
1753
- const pendingResolve = resolveApprovalChoice;
1754
- resolveApprovalChoice = null;
2049
+ const options = current.approvalPrompt?.options ?? [];
2050
+ const chosen = options[index]?.decision ?? { kind: 'deny' };
2051
+ const pendingResolve = resolvePermissionDecision;
2052
+ resolvePermissionDecision = null;
1755
2053
  store.update((next) => ({
1756
- ...next,
1757
- approvalCursor: getDefaultApprovalCursor(),
2054
+ ...resumeBusyClock(next, Date.now()),
2055
+ approvalCursor: getDefaultApprovalCursor(options.length),
1758
2056
  approvalPrompt: null,
2057
+ approvalScrollOffset: 0,
1759
2058
  status: current.approvalPrompt?.returnStatus ?? next.status,
1760
2059
  }));
1761
- pendingResolve?.(choice);
2060
+ pendingResolve?.(chosen);
1762
2061
  };
1763
2062
  const refreshServerModels = async () => {
1764
2063
  currentServerModels = await models.fetchServerModels({ config: authConfig });
@@ -1792,7 +2091,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1792
2091
  selectedModelId: selected,
1793
2092
  serverModels: fresh,
1794
2093
  });
1795
- await saveActiveSession();
2094
+ if (!(await saveActiveSession()))
2095
+ return;
1796
2096
  syncShellStateFromSession();
1797
2097
  appendStaticEntry({
1798
2098
  body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
@@ -1801,23 +2101,24 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1801
2101
  });
1802
2102
  };
1803
2103
  const openJobsPicker = () => {
2104
+ if (store.getState().jobsPickerOpen)
2105
+ return;
1804
2106
  syncBackgroundJobsState();
1805
2107
  store.update((current) => ({
1806
2108
  ...current,
1807
2109
  commandCursor: 0,
1808
- cursor: 0,
1809
- input: '',
1810
2110
  jobsPickerExpandedId: null,
1811
2111
  jobsPickerIndex: 0,
1812
2112
  jobsPickerOpen: true,
2113
+ jobsPickerReturnStatus: current.status,
1813
2114
  status: 'Background jobs',
1814
2115
  }));
2116
+ scheduleLiveFrameRemount();
1815
2117
  };
1816
2118
  const appendJobOutputEntry = async (jobId) => {
1817
2119
  const id = jobId.trim();
1818
2120
  await collectBackgroundJobUiOutputMutations({
1819
2121
  session,
1820
- projectIndex,
1821
2122
  jobId: id,
1822
2123
  });
1823
2124
  const job = listBackgroundJobs().find((candidate) => candidate.id === id);
@@ -1843,32 +2144,51 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1843
2144
  const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
1844
2145
  return current.backgroundJobs[index]?.id ?? null;
1845
2146
  };
2147
+ const deferredJobMutationIds = new Set();
2148
+ const drainDeferredJobMutations = async () => {
2149
+ if (deferredJobMutationIds.size === 0)
2150
+ return;
2151
+ const pending = [...deferredJobMutationIds];
2152
+ deferredJobMutationIds.clear();
2153
+ for (const jobId of pending) {
2154
+ await collectBackgroundJobUiOutputMutations({ session, jobId });
2155
+ }
2156
+ syncBackgroundJobsState();
2157
+ };
1846
2158
  const handleJobsPickerOutput = async () => {
1847
2159
  const jobId = selectedJobsPickerId();
1848
2160
  if (!jobId)
1849
2161
  return;
1850
- await collectBackgroundJobUiOutputMutations({
1851
- session,
1852
- projectIndex,
1853
- jobId,
1854
- });
1855
- syncBackgroundJobsState();
2162
+ if (store.getState().busy) {
2163
+ deferredJobMutationIds.add(jobId);
2164
+ }
2165
+ else {
2166
+ await collectBackgroundJobUiOutputMutations({
2167
+ session,
2168
+ jobId,
2169
+ });
2170
+ }
1856
2171
  store.update((current) => ({
1857
2172
  ...current,
1858
2173
  jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
1859
2174
  }));
2175
+ syncBackgroundJobsState();
1860
2176
  };
1861
2177
  const handleJobsPickerKill = async () => {
1862
2178
  const jobId = selectedJobsPickerId();
1863
2179
  if (!jobId)
1864
2180
  return;
1865
2181
  const killed = await killBackgroundJob(jobId);
1866
- await collectBackgroundJobUiKillMutations({
1867
- session,
1868
- projectIndex,
1869
- jobId,
1870
- result: killed,
1871
- });
2182
+ if (store.getState().busy) {
2183
+ deferredJobMutationIds.add(jobId);
2184
+ }
2185
+ else {
2186
+ await collectBackgroundJobUiKillMutations({
2187
+ session,
2188
+ jobId,
2189
+ result: killed,
2190
+ });
2191
+ }
1872
2192
  syncBackgroundJobsState();
1873
2193
  if (!killed.ok) {
1874
2194
  appendError(killed.error ?? 'Background job kill failed.');
@@ -1911,6 +2231,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1911
2231
  await switchToModel(selected.id);
1912
2232
  }
1913
2233
  catch (error) {
2234
+ if (exitForAuthenticationError(error))
2235
+ return;
1914
2236
  appendError(error.message);
1915
2237
  }
1916
2238
  };
@@ -1960,15 +2282,24 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1960
2282
  store.update((next) => ({
1961
2283
  ...next,
1962
2284
  busy: true,
2285
+ busyPausedAt: null,
1963
2286
  busySince: resumeStartedAt,
1964
2287
  clockNow: resumeStartedAt,
1965
2288
  status: 'Loading session...',
1966
2289
  }));
1967
2290
  try {
1968
2291
  const snapshot = await loadInteractiveSession(selected.id);
1969
- applySessionSnapshot(session, snapshot);
2292
+ const requestedModelId = Number(snapshot.modelId);
2293
+ const knownModels = currentServerModels.models.some((model) => model.id === requestedModelId)
2294
+ ? currentServerModels
2295
+ : await refreshServerModels();
2296
+ const { modelId: resolvedModelId, fellBack } = models.resolveResumedSessionModel(requestedModelId, knownModels);
2297
+ applySessionSnapshot(session, snapshot, {
2298
+ modelOverride: fellBack ? resolvedModelId : undefined,
2299
+ });
1970
2300
  setBackgroundJobSession(session.sessionId);
1971
2301
  setScratchSession(session.sessionId);
2302
+ setImageStoreSession(session.sessionId);
1972
2303
  syncBackgroundJobsState();
1973
2304
  setTodoSession(session.sessionId);
1974
2305
  syncTodosState();
@@ -1976,6 +2307,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1976
2307
  store.update((next) => ({
1977
2308
  ...next,
1978
2309
  busy: false,
2310
+ busyPausedAt: null,
1979
2311
  busySince: null,
1980
2312
  resumePickerFilter: '',
1981
2313
  resumePickerIndex: 0,
@@ -1989,14 +2321,26 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1989
2321
  kind: 'system',
1990
2322
  title: 'Session',
1991
2323
  },
2324
+ ...(fellBack
2325
+ ? [
2326
+ {
2327
+ body: `This session's model is no longer available — switched to ${formatModelLabel(resolvedModelId, knownModels.models)}. Conversation history preserved.`,
2328
+ kind: 'system',
2329
+ title: 'Model',
2330
+ },
2331
+ ]
2332
+ : []),
1992
2333
  ...buildTranscriptFromSessionHistory(session.history),
1993
2334
  ]);
1994
2335
  await remountTui();
1995
2336
  }
1996
2337
  catch (error) {
2338
+ if (exitForAuthenticationError(error))
2339
+ return;
1997
2340
  store.update((next) => ({
1998
2341
  ...next,
1999
2342
  busy: false,
2343
+ busyPausedAt: null,
2000
2344
  busySince: null,
2001
2345
  resumePickerFilter: '',
2002
2346
  resumePickerIndex: 0,
@@ -2020,6 +2364,127 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2020
2364
  }));
2021
2365
  await handleSubmit(queued.body);
2022
2366
  };
2367
+ let turnMessageCounter = 0;
2368
+ const upsertTurnMessage = (id, patch) => {
2369
+ store.update((current) => ({
2370
+ ...current,
2371
+ turnMessages: current.turnMessages.map((message) => message.id === id ? { ...message, ...patch } : message),
2372
+ }));
2373
+ };
2374
+ const recoverUnacknowledgedMessages = (autoSubmits = true) => {
2375
+ if (unacknowledged.size === 0)
2376
+ return;
2377
+ const stranded = [...unacknowledged.values()];
2378
+ unacknowledged = new Map();
2379
+ for (const { row } of stranded) {
2380
+ upsertTurnMessage(row, {
2381
+ state: 'queued',
2382
+ note: autoSubmits
2383
+ ? 'not read — sending as the next prompt'
2384
+ : 'not read — left in the composer',
2385
+ });
2386
+ }
2387
+ const existing = store.getState().queuedMessage;
2388
+ const combined = [
2389
+ ...stranded.map((entry) => entry.queued),
2390
+ ...(existing ? [existing] : []),
2391
+ ];
2392
+ const body = combined
2393
+ .map((entry) => entry.body)
2394
+ .filter((entry) => entry.trim())
2395
+ .join('\n\n');
2396
+ if (!body.trim())
2397
+ return;
2398
+ scheduleLiveFrameRemount();
2399
+ store.update((current) => ({
2400
+ ...current,
2401
+ queuedMessage: {
2402
+ body,
2403
+ imageAttachments: combined.flatMap((entry) => entry.imageAttachments),
2404
+ pastedChunks: combined.flatMap((entry) => entry.pastedChunks),
2405
+ },
2406
+ }));
2407
+ };
2408
+ const fireQueuedMessage = async () => {
2409
+ const state = store.getState();
2410
+ const queued = state.queuedMessage;
2411
+ if (!queued || !state.busy || exiting)
2412
+ return;
2413
+ const text = (queued.pastedChunks.length
2414
+ ? expandPastedChunks(queued.body, queued.pastedChunks)
2415
+ : queued.body).trim();
2416
+ const images = queued.imageAttachments.filter((attachment) => queued.body.includes(`[Image #${attachment.index}]`));
2417
+ if (!text && images.length === 0)
2418
+ return;
2419
+ const turnId = activeServerTurnId;
2420
+ const blockedReason = !turnId ? 'waiting for the turn to end' : null;
2421
+ const existingRow = blockedRows.get(queued);
2422
+ if (existingRow !== undefined) {
2423
+ upsertTurnMessage(existingRow, {
2424
+ state: 'queued',
2425
+ ...(blockedReason ? { note: blockedReason } : {}),
2426
+ });
2427
+ if (blockedReason || !turnId)
2428
+ return;
2429
+ }
2430
+ const id = existingRow ?? ++turnMessageCounter;
2431
+ const messageId = `m${id}_${Date.now().toString(36)}`;
2432
+ if (existingRow === undefined) {
2433
+ scheduleLiveFrameRemount();
2434
+ store.update((current) => ({
2435
+ ...current,
2436
+ turnMessages: [
2437
+ ...current.turnMessages,
2438
+ {
2439
+ id,
2440
+ text,
2441
+ state: blockedReason ? 'queued' : 'sending',
2442
+ ...(blockedReason ? { note: blockedReason } : {}),
2443
+ },
2444
+ ],
2445
+ }));
2446
+ }
2447
+ if (blockedReason || !turnId) {
2448
+ blockedRows.set(queued, id);
2449
+ return;
2450
+ }
2451
+ blockedRows.delete(queued);
2452
+ unacknowledged.set(messageId, { row: id, queued });
2453
+ scheduleLiveFrameRemount();
2454
+ store.update((current) => current.queuedMessage === queued
2455
+ ? { ...current, queuedMessage: null }
2456
+ : current);
2457
+ queueTurnEntry({
2458
+ body: text,
2459
+ kind: 'user',
2460
+ preformatted: queued.pastedChunks.length > 0,
2461
+ title: 'You · sent mid-turn',
2462
+ });
2463
+ const rollback = (note) => {
2464
+ if (!unacknowledged.delete(messageId))
2465
+ return;
2466
+ upsertTurnMessage(id, { state: 'queued', note });
2467
+ scheduleLiveFrameRemount();
2468
+ store.update((current) => current.queuedMessage ? current : { ...current, queuedMessage: queued });
2469
+ };
2470
+ let outcome;
2471
+ try {
2472
+ outcome = await chat.postInterjection({
2473
+ config: authConfig,
2474
+ turnId,
2475
+ text,
2476
+ messageId,
2477
+ imageAttachments: images,
2478
+ });
2479
+ }
2480
+ catch (error) {
2481
+ rollback(`not sent — ${error?.message ?? 'request failed'}`);
2482
+ return;
2483
+ }
2484
+ if (outcome === 'stale') {
2485
+ rollback('turn ended — sending as the next prompt');
2486
+ }
2487
+ };
2023
2488
  const handleSubmit = async (rawInput) => {
2024
2489
  const chunks = store.getState().pastedChunks;
2025
2490
  const expanded = chunks.length
@@ -2031,17 +2496,28 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2031
2496
  if (!input || exiting)
2032
2497
  return;
2033
2498
  if (input.startsWith('/')) {
2499
+ if (isJobsPickerCommand(input)) {
2500
+ store.update((current) => ({
2501
+ ...current,
2502
+ cursor: 0,
2503
+ input: '',
2504
+ pastedChunks: [],
2505
+ }));
2506
+ openJobsPicker();
2507
+ return;
2508
+ }
2034
2509
  appendTurnAwareEntry({
2035
- body: "Slash commands can't be queued while a turn is running.",
2510
+ body: "Slash commands can't be queued while a turn is running. Ctrl+B opens background jobs.",
2036
2511
  kind: 'system',
2037
2512
  title: 'Queued',
2038
2513
  });
2039
2514
  return;
2040
2515
  }
2041
2516
  const pending = store.getState();
2517
+ const body = String(rawInput ?? '');
2042
2518
  const snapshot = {
2043
- body: String(rawInput ?? ''),
2044
- imageAttachments: pending.imageAttachments,
2519
+ body,
2520
+ imageAttachments: pending.imageAttachments.filter((attachment) => body.includes(`[Image #${attachment.index}]`)),
2045
2521
  pastedChunks: pending.pastedChunks,
2046
2522
  };
2047
2523
  scheduleLiveFrameRemount();
@@ -2056,11 +2532,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2056
2532
  }));
2057
2533
  return;
2058
2534
  }
2059
- const imageAttachments = store.getState().imageAttachments;
2535
+ const imageAttachments = store
2536
+ .getState()
2537
+ .imageAttachments.filter((attachment) => input.includes(`[Image #${attachment.index}]`));
2060
2538
  scheduleLiveFrameRemount();
2061
2539
  store.update((current) => ({
2062
2540
  ...current,
2063
2541
  cursor: 0,
2542
+ imageAttachments: [],
2064
2543
  input: '',
2065
2544
  pastedChunks: [],
2066
2545
  promptHistory: appendPromptToHistory(current.promptHistory, input),
@@ -2076,6 +2555,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2076
2555
  requestExit();
2077
2556
  return;
2078
2557
  }
2558
+ if (input === '/logout') {
2559
+ store.update((current) => ({
2560
+ ...current,
2561
+ busy: true,
2562
+ status: 'Signing out...',
2563
+ }));
2564
+ try {
2565
+ await logoutFromServer({ config: authConfig });
2566
+ }
2567
+ catch {
2568
+ }
2569
+ clearCliAuthConfig(session.env);
2570
+ signedOut = true;
2571
+ requestExit();
2572
+ return;
2573
+ }
2079
2574
  if (input === '/help') {
2080
2575
  appendStaticEntry({
2081
2576
  body: formatInteractiveHelpText(),
@@ -2092,6 +2587,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2092
2587
  });
2093
2588
  return;
2094
2589
  }
2590
+ if (input === '/report') {
2591
+ const opened = await openUrl(REPORT_ISSUE_URL);
2592
+ appendStaticEntries(buildReportTranscriptEntries(opened));
2593
+ return;
2594
+ }
2095
2595
  if (input === '/jobs' || input.startsWith('/jobs ')) {
2096
2596
  const jobsArgs = input.slice('/jobs'.length).trim();
2097
2597
  if (!jobsArgs) {
@@ -2104,7 +2604,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2104
2604
  const killed = await killBackgroundJob(jobId);
2105
2605
  await collectBackgroundJobUiKillMutations({
2106
2606
  session,
2107
- projectIndex,
2108
2607
  jobId,
2109
2608
  result: killed,
2110
2609
  });
@@ -2146,14 +2645,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2146
2645
  });
2147
2646
  }
2148
2647
  catch (error) {
2648
+ if (exitForAuthenticationError(error))
2649
+ return;
2149
2650
  appendError(error.message);
2150
2651
  }
2151
2652
  finally {
2152
- store.update((current) => ({
2153
- ...current,
2154
- busy: false,
2155
- status: 'Ready',
2156
- }));
2653
+ if (!exiting) {
2654
+ store.update((current) => ({
2655
+ ...current,
2656
+ busy: false,
2657
+ status: 'Ready',
2658
+ }));
2659
+ }
2157
2660
  }
2158
2661
  return;
2159
2662
  }
@@ -2167,14 +2670,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2167
2670
  await openResumePicker();
2168
2671
  }
2169
2672
  catch (error) {
2673
+ if (exitForAuthenticationError(error))
2674
+ return;
2170
2675
  appendError(error.message);
2171
2676
  }
2172
2677
  finally {
2173
- store.update((current) => ({
2174
- ...current,
2175
- busy: false,
2176
- status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2177
- }));
2678
+ if (!exiting) {
2679
+ store.update((current) => ({
2680
+ ...current,
2681
+ busy: false,
2682
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2683
+ }));
2684
+ }
2178
2685
  }
2179
2686
  return;
2180
2687
  }
@@ -2194,43 +2701,85 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2194
2701
  }
2195
2702
  }
2196
2703
  catch (error) {
2704
+ if (exitForAuthenticationError(error))
2705
+ return;
2197
2706
  appendError(error.message);
2198
2707
  }
2199
2708
  finally {
2709
+ if (!exiting) {
2710
+ store.update((current) => ({
2711
+ ...current,
2712
+ busy: false,
2713
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2714
+ }));
2715
+ }
2716
+ }
2717
+ return;
2718
+ }
2719
+ if (input === '/new') {
2720
+ newConversationInFlight = true;
2721
+ store.update((current) => ({
2722
+ ...current,
2723
+ busy: true,
2724
+ busyPausedAt: null,
2725
+ busySince: Date.now(),
2726
+ imageAttachments: [],
2727
+ queuedMessage: null,
2728
+ status: 'Starting a new conversation...',
2729
+ }));
2730
+ if (!(await saveActiveSessionOrAbort())) {
2731
+ newConversationInFlight = false;
2200
2732
  store.update((current) => ({
2201
2733
  ...current,
2202
2734
  busy: false,
2203
- status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2735
+ busyPausedAt: null,
2736
+ busySince: null,
2737
+ status: 'Ready',
2204
2738
  }));
2739
+ await flushQueuedMessage();
2740
+ return;
2205
2741
  }
2206
- return;
2207
- }
2208
- if (input === '/clear') {
2209
- clearConversation(session);
2742
+ startNewConversation(session);
2743
+ setBackgroundJobSession(session.sessionId);
2744
+ setScratchSession(session.sessionId);
2745
+ setImageStoreSession(session.sessionId);
2746
+ setTodoSession(session.sessionId);
2210
2747
  clearTodos();
2748
+ syncBackgroundJobsState();
2211
2749
  syncTodosState();
2212
2750
  latestUsageSummary = null;
2213
- await saveActiveSession();
2214
2751
  store.replaceTranscript([
2215
2752
  {
2216
- body: 'Conversation cleared.',
2753
+ body: 'Started a new conversation. The previous session remains saved.',
2217
2754
  kind: 'system',
2218
- title: 'System',
2755
+ title: 'Session',
2219
2756
  },
2220
2757
  ]);
2758
+ await saveActiveSession();
2221
2759
  syncShellStateFromSession();
2222
- store.update((current) => ({ ...current, queuedMessage: null }));
2760
+ newConversationInFlight = false;
2761
+ store.update((current) => ({
2762
+ ...current,
2763
+ busy: false,
2764
+ busyPausedAt: null,
2765
+ busySince: null,
2766
+ status: 'Ready',
2767
+ }));
2223
2768
  await remountTui();
2769
+ await flushQueuedMessage();
2224
2770
  return;
2225
2771
  }
2226
2772
  latestUsageSummary = null;
2227
2773
  disarmExitConfirm();
2774
+ unacknowledged = new Map();
2228
2775
  const turnStartedAt = Date.now();
2229
2776
  const turnGeneration = ++activeTurnGeneration;
2230
2777
  const turnAbort = new AbortController();
2231
2778
  activeTurnAbort = turnAbort;
2232
2779
  lastTurnStartedAt = turnStartedAt;
2233
2780
  todosTouchedThisTurn = false;
2781
+ clearTodos();
2782
+ syncTodosState();
2234
2783
  const userEntry = {
2235
2784
  body: input,
2236
2785
  kind: 'user',
@@ -2239,11 +2788,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2239
2788
  };
2240
2789
  pendingTurnEntries = [];
2241
2790
  queueTurnEntry(userEntry);
2791
+ resetThinkingPacer();
2792
+ activeTurnImageAttachments = imageAttachments;
2242
2793
  store.update((current) => ({
2243
2794
  ...current,
2244
2795
  activeTurnInput: input,
2245
2796
  activeTurnInputPreformatted: preformatted,
2797
+ analyzingImages: 0,
2798
+ generatingImage: false,
2246
2799
  busy: true,
2800
+ busyPausedAt: null,
2247
2801
  busySince: turnStartedAt,
2248
2802
  clockNow: turnStartedAt,
2249
2803
  status: 'Running turn...',
@@ -2252,23 +2806,44 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2252
2806
  transcriptScrollOffset: 0,
2253
2807
  tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
2254
2808
  turnCounter: current.turnCounter + 1,
2809
+ turnMessages: [],
2255
2810
  workingTools: [],
2256
2811
  }));
2257
2812
  try {
2258
2813
  const result = await chat.sendServerUserMessage({
2259
2814
  config: authConfig,
2260
- projectIndex,
2261
2815
  session,
2262
2816
  input,
2263
2817
  imageAttachments,
2264
2818
  signal: turnAbort.signal,
2819
+ onTurnStart: (serverTurnId) => {
2820
+ if (turnGeneration !== activeTurnGeneration)
2821
+ return;
2822
+ activeServerTurnId = serverTurnId;
2823
+ },
2824
+ onInterjectionDelivered: (event) => {
2825
+ if (turnGeneration !== activeTurnGeneration)
2826
+ return;
2827
+ for (const messageId of event.messageIds ?? []) {
2828
+ const pending = unacknowledged.get(messageId);
2829
+ if (!pending)
2830
+ continue;
2831
+ unacknowledged.delete(messageId);
2832
+ upsertTurnMessage(pending.row, {
2833
+ state: 'delivered',
2834
+ note: undefined,
2835
+ });
2836
+ }
2837
+ },
2265
2838
  });
2266
2839
  if (turnGeneration !== activeTurnGeneration)
2267
2840
  return;
2268
2841
  latestUsageSummary = result.usageSummary ?? null;
2269
2842
  const turnEntries = takePendingTurnEntries();
2270
- await saveActiveSession();
2843
+ if (!(await saveActiveSession()))
2844
+ return;
2271
2845
  syncShellStateFromSession();
2846
+ activeTurnImageAttachments = [];
2272
2847
  store.update((current) => ({
2273
2848
  ...current,
2274
2849
  activeTurnInput: '',
@@ -2304,10 +2879,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2304
2879
  store.update((current) => ({
2305
2880
  ...current,
2306
2881
  busy: false,
2882
+ busyPausedAt: null,
2307
2883
  busySince: null,
2308
2884
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2309
2885
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2310
2886
  }));
2887
+ recoverUnacknowledgedMessages();
2311
2888
  await flushQueuedMessage();
2312
2889
  }
2313
2890
  catch (error) {
@@ -2318,14 +2895,19 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2318
2895
  return;
2319
2896
  }
2320
2897
  const cancelled = isTurnCancelledError(error);
2898
+ if (exitForAuthenticationError(error))
2899
+ return;
2900
+ const failedTurnAttachments = activeTurnImageAttachments;
2901
+ activeTurnImageAttachments = [];
2321
2902
  store.update((current) => ({
2322
2903
  ...current,
2323
2904
  activeTurnInput: '',
2324
2905
  activeTurnInputPreformatted: false,
2325
2906
  busy: false,
2907
+ busyPausedAt: null,
2326
2908
  busySince: null,
2327
2909
  commandLog: [],
2328
- imageAttachments: [],
2910
+ imageAttachments: failedTurnAttachments,
2329
2911
  status: cancelled ? 'Ready' : 'Turn failed',
2330
2912
  thinkingTitle: '',
2331
2913
  thinkingNotes: [],
@@ -2342,13 +2924,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2342
2924
  title: 'System',
2343
2925
  },
2344
2926
  ]);
2345
- await saveActiveSession();
2927
+ if (!(await saveActiveSession()))
2928
+ return;
2346
2929
  }
2347
2930
  else {
2348
2931
  appendStaticEntries(turnEntries);
2349
2932
  appendError(error.message);
2933
+ appendStaticEntries(buildTurnFailureHintEntries(cancelled));
2350
2934
  }
2351
2935
  await remountTui();
2936
+ recoverUnacknowledgedMessages(!cancelled);
2352
2937
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2353
2938
  await flushQueuedMessage();
2354
2939
  }
@@ -2359,24 +2944,43 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2359
2944
  }
2360
2945
  if (turnGeneration === activeTurnGeneration) {
2361
2946
  lastTurnStartedAt = null;
2947
+ activeServerTurnId = null;
2362
2948
  }
2949
+ recoverUnacknowledgedMessages();
2950
+ await drainDeferredJobMutations();
2363
2951
  }
2364
2952
  };
2953
+ session.onImageAnalysis = (activeImageCount) => {
2954
+ store.update((current) => ({
2955
+ ...current,
2956
+ analyzingImages: Math.max(0, activeImageCount),
2957
+ generatingImage: activeImageCount > 0 ? false : current.generatingImage,
2958
+ }));
2959
+ };
2960
+ session.onImageGeneration = (active) => {
2961
+ store.update((current) => ({
2962
+ ...current,
2963
+ generatingImage: Boolean(active),
2964
+ analyzingImages: active ? 0 : current.analyzingImages,
2965
+ }));
2966
+ };
2365
2967
  session.onStatus = (message) => {
2366
2968
  const panel = thinkingPanelFromStatus(message);
2367
2969
  store.update((current) => ({
2368
2970
  ...current,
2369
2971
  status: message,
2370
- thinkingTitle: panel?.title ?? current.thinkingTitle,
2371
- thinkingNotes: panel?.notes ?? current.thinkingNotes,
2372
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2972
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2373
2973
  }));
2974
+ queueThinkingUpdate({
2975
+ title: panel ? panel.title : liveStatusPanelTitle(message),
2976
+ notes: panel ? panel.notes : [],
2977
+ });
2374
2978
  };
2375
2979
  session.onContextLog = (message) => {
2376
2980
  store.update((current) => ({
2377
2981
  ...current,
2378
2982
  contextStatus: message,
2379
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2983
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2380
2984
  }));
2381
2985
  };
2382
2986
  session.onToolEvent = (event) => {
@@ -2396,26 +3000,20 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2396
3000
  }
2397
3001
  store.appendWorkingTool(buildWorkingToolEntry(event));
2398
3002
  };
2399
- const jobDetailLines = (jobId) => {
2400
- const buffered = getJobBufferedOutput(jobId);
2401
- if (!buffered)
2402
- return [];
2403
- const lines = buffered.output
2404
- .replace(/\r\n/g, '\n')
2405
- .replace(/\r/g, '\n')
2406
- .split('\n')
2407
- .filter((line) => line.trim().length > 0);
2408
- const visible = lines.slice(-20);
2409
- return buffered.droppedChars > 0
2410
- ? [
2411
- `... (${buffered.droppedChars} chars of older output dropped) ...`,
2412
- ...visible,
2413
- ]
2414
- : visible;
2415
- };
2416
3003
  const syncBackgroundJobsState = () => {
3004
+ const expandedId = store.getState().jobsPickerExpandedId;
2417
3005
  const jobsForDisplay = listBackgroundJobs().map((job) => {
2418
- const preview = getJobOutputPreview(job.id, 3);
3006
+ const expanded = job.id === expandedId;
3007
+ const preview = getJobOutputPreview(job.id, expanded ? 20 : 3);
3008
+ const dropped = preview?.droppedChars ?? 0;
3009
+ const detailLines = !expanded
3010
+ ? []
3011
+ : dropped > 0
3012
+ ? [
3013
+ `... (${dropped} chars of older output dropped) ...`,
3014
+ ...(preview?.tailLines ?? []),
3015
+ ]
3016
+ : (preview?.tailLines ?? []);
2419
3017
  return {
2420
3018
  id: job.id,
2421
3019
  command: job.command,
@@ -2424,8 +3022,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2424
3022
  startedAt: job.startedAt,
2425
3023
  endedAt: job.endedAt,
2426
3024
  firstOutputLine: preview?.firstLine ?? '',
2427
- tailLines: preview?.tailLines ?? [],
2428
- detailLines: jobDetailLines(job.id),
3025
+ tailLines: (preview?.tailLines ?? []).slice(-3),
3026
+ detailLines,
2429
3027
  };
2430
3028
  });
2431
3029
  store.update((current) => ({
@@ -2442,54 +3040,54 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2442
3040
  appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2443
3041
  }
2444
3042
  });
2445
- projectIndex.onStatus = session.onStatus;
2446
- projectIndex.onContextLog = session.onContextLog;
2447
3043
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2448
- session.confirmCommand = async (command) => {
3044
+ session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
3045
+ session.requestPermission = async (request) => {
3046
+ const deny = { kind: 'deny' };
2449
3047
  if (exiting)
2450
- return false;
3048
+ return deny;
2451
3049
  if (sessionAutoYes)
2452
- return true;
2453
- const choice = await openApprovalPrompt('Approve command?', command);
2454
- if (choice === 'a') {
2455
- setAgentMode('auto-accept');
2456
- syncShellStateFromSession();
3050
+ return { kind: 'once' };
3051
+ const decision = await openApprovalPrompt(request);
3052
+ if (decision.kind === 'always-bucket') {
2457
3053
  appendTurnAwareEntry({
2458
- body: 'Auto-approve enabled for the rest of this session.',
3054
+ body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
2459
3055
  kind: 'system',
2460
3056
  title: 'Approvals',
2461
3057
  });
2462
- return true;
2463
3058
  }
2464
- return choice === 'y';
2465
- };
2466
- session.confirmPatch = async (filePath, patch) => {
2467
- if (exiting)
2468
- return false;
2469
- if (sessionAutoYes)
2470
- return true;
2471
- const choice = await openApprovalPrompt('Approve patch?', 'Review changes before applying.', {
2472
- diff: patch,
2473
- filePath,
2474
- });
2475
- if (choice === 'a') {
2476
- setAgentMode('auto-accept');
2477
- syncShellStateFromSession();
3059
+ else if (decision.kind === 'always-prefix') {
2478
3060
  appendTurnAwareEntry({
2479
- body: 'Auto-approve enabled for the rest of this session.',
3061
+ body: `Allowed for the rest of this session: commands starting with \`${decision.prefix}\`.`,
2480
3062
  kind: 'system',
2481
3063
  title: 'Approvals',
2482
3064
  });
2483
- return true;
2484
3065
  }
2485
- return choice === 'y';
3066
+ else if (decision.kind === 'always-origin') {
3067
+ appendTurnAwareEntry({
3068
+ body: `Allowed for the rest of this session: browser actions on ${decision.origin}.`,
3069
+ kind: 'system',
3070
+ title: 'Approvals',
3071
+ });
3072
+ }
3073
+ return decision;
2486
3074
  };
2487
3075
  const shellInputHandlers = {
3076
+ getApprovalScrollLimit: () => {
3077
+ const contentWidth = frameContentWidth(terminalCols);
3078
+ return approvalScrollLimit(store.getState().approvalPrompt, contentWidth, terminalRows);
3079
+ },
3080
+ getComposerInputWidth: () => composerInputWidth(terminalCols, store.getState()),
2488
3081
  getTranscriptScrollLimit: () => {
2489
- const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
3082
+ const contentWidth = frameContentWidth(terminalCols);
2490
3083
  const blocks = store.getState().transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
2491
3084
  return blocks.reduce((total, block, index) => total + block.length + (index > 0 ? 1 : 0), 0);
2492
3085
  },
3086
+ getUserInputViewport: () => {
3087
+ const nowMs = Date.now();
3088
+ const state = store.getState();
3089
+ return userInputViewportForFrame(state, terminalCols, terminalRows, spinnerFrame, busyElapsedSeconds(state, nowMs), nowMs);
3090
+ },
2493
3091
  onCancelTurn: cancelActiveTurn,
2494
3092
  onCycleAgentMode: cycleAgentMode,
2495
3093
  onCtrlC: handleCtrlC,
@@ -2498,13 +3096,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2498
3096
  onLiveFrameShapeChange: scheduleLiveFrameRemount,
2499
3097
  onRequestExit: requestExit,
2500
3098
  onResolveApproval: handleInlineApprovalChoice,
3099
+ onResolveUserInput: handleInlineUserInput,
2501
3100
  onResumeSession: handleInlineResumeSelection,
2502
3101
  onSelectionCopy: handleAppSelectionCopy,
2503
3102
  onSelectModel: handleInlineModelSelection,
3103
+ onOpenJobsPicker: openJobsPicker,
2504
3104
  onJobsPickerOutput: handleJobsPickerOutput,
2505
3105
  onJobsPickerKill: handleJobsPickerKill,
2506
3106
  onSudoPasswordInput: handleSudoPasswordInput,
2507
3107
  onSubmit: handleSubmit,
3108
+ onFireQueuedMessage: fireQueuedMessage,
2508
3109
  };
2509
3110
  const unsubscribe = store.subscribe(() => {
2510
3111
  renderCurrentFrame();
@@ -2512,6 +3113,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2512
3113
  const spinnerTimer = setInterval(() => {
2513
3114
  spinnerFrame = (spinnerFrame + 1) % 6;
2514
3115
  if (store.getState().busy) {
3116
+ flushPendingThinking();
2515
3117
  renderCurrentFrame();
2516
3118
  }
2517
3119
  }, 120);
@@ -2567,10 +3169,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2567
3169
  killAllBackgroundJobs();
2568
3170
  setBackgroundJobSession(null);
2569
3171
  setScratchSession(null);
3172
+ setImageStoreSession(null);
2570
3173
  setTodoSession(null);
2571
3174
  setBackgroundJobUpdateHook(null);
2572
3175
  await bridge.close();
3176
+ releaseTerminalWrites();
2573
3177
  setCommandOutputHook(null);
2574
3178
  }
2575
3179
  });
3180
+ if (fatalError)
3181
+ throw fatalError;
3182
+ return { signedOut };
2576
3183
  }