@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.10

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 (64) hide show
  1. package/README.md +49 -3
  2. package/dist/bin/ai.js +83 -197
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +4 -4
  6. package/dist/src/api/browser-login.js +72 -19
  7. package/dist/src/api/chat.js +182 -35
  8. package/dist/src/api/http.js +65 -4
  9. package/dist/src/api/models.js +33 -22
  10. package/dist/src/artifact-policy.js +3 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +0 -5
  13. package/dist/src/client-environment.js +2 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +19 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/executor.js +48 -12
  18. package/dist/src/help-text.js +30 -13
  19. package/dist/src/patcher.js +97 -12
  20. package/dist/src/project-index.js +13 -1
  21. package/dist/src/project-orientation.js +99 -0
  22. package/dist/src/scanner.js +50 -12
  23. package/dist/src/scratch-dir.js +75 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +0 -19
  26. package/dist/src/session-store.js +52 -21
  27. package/dist/src/session.js +8 -0
  28. package/dist/src/todo-list.js +106 -0
  29. package/dist/src/tool-executor.js +194 -21
  30. package/dist/src/tools/delete-file.js +23 -5
  31. package/dist/src/tools/index.js +6 -0
  32. package/dist/src/tools/patch-file.js +33 -7
  33. package/dist/src/tools/path-suggest.js +81 -8
  34. package/dist/src/tools/read-document.js +2 -2
  35. package/dist/src/tools/read-file.js +17 -8
  36. package/dist/src/tools/replace-document-text.js +10 -12
  37. package/dist/src/tools/restore-checkpoint.js +1 -1
  38. package/dist/src/tools/run-command.js +109 -24
  39. package/dist/src/tools/run-node-script.js +27 -5
  40. package/dist/src/tools/shell-job-kill.js +48 -0
  41. package/dist/src/tools/shell-job-output.js +51 -0
  42. package/dist/src/tools/str-replace.js +33 -7
  43. package/dist/src/tools/undo-edit.js +1 -1
  44. package/dist/src/tools/update-todos.js +27 -0
  45. package/dist/src/tools/write-file.js +26 -6
  46. package/dist/src/tree-sitter-runtime.js +8 -1
  47. package/dist/src/turn-failure-marker.js +11 -0
  48. package/dist/src/ui/prompt-history-store.js +1 -1
  49. package/dist/src/ui/repl.js +500 -71
  50. package/dist/src/ui/tui/bridge.js +3 -4
  51. package/dist/src/ui/tui/build-frame.js +393 -100
  52. package/dist/src/ui/tui/markdown-render.js +72 -73
  53. package/dist/src/ui/tui/shell-input.js +75 -17
  54. package/dist/src/ui/tui/terminal-title.js +84 -0
  55. package/dist/src/ui/tui/terminal-writes.js +48 -0
  56. package/dist/src/ui/tui/text.js +158 -4
  57. package/dist/src/utils.js +9 -0
  58. package/dist/src/version.js +0 -6
  59. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  60. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  61. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  62. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  63. package/package.json +27 -16
  64. package/dist/src/markdown-renderer.js +0 -112
@@ -1,13 +1,22 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
- import { buildTuiFrame, renderTranscriptEntryLines } from './tui/build-frame.js';
2
+ import { buildTuiFrame, formatJobElapsed, formatTodoProgress, renderTranscriptEntryLines, } from './tui/build-frame.js';
3
+ import { createTerminalTitleController } from './tui/terminal-title.js';
4
+ import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
3
5
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
4
6
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
5
7
  import { chat, models } from '../api/index.js';
6
8
  import { isTurnCancelledError } from '../api/chat.js';
9
+ import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
10
+ import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
11
+ import { setScratchSession } from '../scratch-dir.js';
7
12
  import { cancelActiveCommand } from '../executor.js';
13
+ import { isTurnFailureMarker } from '../turn-failure-marker.js';
14
+ import { clearCliAuthConfig } from '../api/auth.js';
15
+ import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
8
16
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
9
- import { clearConversation, } from '../session.js';
10
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
17
+ import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
18
+ import { startNewConversation, } from '../session.js';
19
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../session-store.js';
11
20
  import { truncate } from '../utils.js';
12
21
  import { writeClipboardText } from '../core/clipboard.js';
13
22
  import { openUrl } from '../core/open-url.js';
@@ -103,8 +112,12 @@ export const SLASH_COMMANDS = [
103
112
  description: 'Open the session picker',
104
113
  },
105
114
  {
106
- command: '/clear',
107
- description: 'Clear conversation history',
115
+ command: '/jobs',
116
+ description: 'Background jobs: pick to view output or kill',
117
+ },
118
+ {
119
+ command: '/new',
120
+ description: 'start a new conversation; this session remains saved',
108
121
  },
109
122
  {
110
123
  command: '/exit',
@@ -550,6 +563,31 @@ function buildFileChangeEntry(event) {
550
563
  title: skipped ? `${verb} skipped: ${filePath}` : `${verb} failed: ${filePath}`,
551
564
  };
552
565
  }
566
+ if (result?.scratch === true) {
567
+ const scratchName = truncate(filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? filePath, 72);
568
+ if (call.name === 'delete_file') {
569
+ return {
570
+ body: '',
571
+ filePath,
572
+ kind: 'tool',
573
+ title: result?.deleted === true
574
+ ? `Removed scratch file: ${scratchName}`
575
+ : `Scratch delete skipped: ${scratchName}`,
576
+ };
577
+ }
578
+ const content = typeof call.args?.content === 'string' ? call.args.content : '';
579
+ const lineSummary = call.name === 'write_file' && content
580
+ ? ` (${splitDiffLines(content).length} lines)`
581
+ : '';
582
+ return {
583
+ body: '',
584
+ filePath,
585
+ kind: 'tool',
586
+ title: call.name === 'write_file'
587
+ ? `Wrote scratch file: ${scratchName}${lineSummary}`
588
+ : `Edited scratch file: ${scratchName}`,
589
+ };
590
+ }
553
591
  if (call.name === 'undo_edit') {
554
592
  const dryRun = result?.dryRun === true ||
555
593
  result?.dry_run === true ||
@@ -643,12 +681,33 @@ function buildWorkingToolEntry(event) {
643
681
  : '';
644
682
  if (call.name === 'run_command') {
645
683
  const command = String(call.args?.command ?? result?.command ?? '').trim();
684
+ if (result?.backgrounded === true && result?.jobId) {
685
+ return {
686
+ body: `$ ${truncate(command, 180)}\nBackground job ${String(result.jobId)} started.${error}`,
687
+ kind: result?.ok === true ? 'tool' : 'error',
688
+ preformatted: true,
689
+ title: 'Shell',
690
+ };
691
+ }
646
692
  return {
647
693
  body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
648
694
  kind: result?.ok === true ? 'tool' : 'error',
649
695
  title: 'Shell',
650
696
  };
651
697
  }
698
+ if (call.name === 'shell_job_output' || call.name === 'shell_job_kill') {
699
+ const jobId = String(result?.jobId ?? call.args?.job_id ?? '').trim();
700
+ const status = String(result?.status ?? '').trim();
701
+ const exitCode = result?.exitCode;
702
+ const stateText = status
703
+ ? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
704
+ : formatToolResultState(result);
705
+ return {
706
+ body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
707
+ kind: result?.ok === true ? 'tool' : 'error',
708
+ title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
709
+ };
710
+ }
652
711
  if (call.name === 'run_node_script') {
653
712
  return {
654
713
  body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
@@ -666,6 +725,32 @@ function buildWorkingToolEntry(event) {
666
725
  title: 'Tool',
667
726
  };
668
727
  }
728
+ function buildBackgroundJobNoticeEntry(snapshot) {
729
+ const command = truncate(snapshot.command, 120);
730
+ const ran = formatJobElapsed((snapshot.endedAt ?? Date.now()) - snapshot.startedAt);
731
+ if (snapshot.status === 'error') {
732
+ return {
733
+ body: `✖ ${snapshot.id} (${command}) failed to start`,
734
+ kind: 'error',
735
+ preformatted: true,
736
+ title: 'Background job',
737
+ };
738
+ }
739
+ if (snapshot.status === 'killed') {
740
+ return {
741
+ body: `■ ${snapshot.id} (${command}) killed · ran ${ran}`,
742
+ kind: 'system',
743
+ preformatted: true,
744
+ title: 'Background job',
745
+ };
746
+ }
747
+ return {
748
+ body: `${snapshot.exitCode === 0 ? '✓' : '✖'} ${snapshot.id} (${command}) exited (code ${snapshot.exitCode ?? 1}) · ran ${ran}`,
749
+ kind: snapshot.exitCode === 0 ? 'system' : 'error',
750
+ preformatted: true,
751
+ title: 'Background job',
752
+ };
753
+ }
669
754
  function findPendingToolCallByName(pendingCalls, name) {
670
755
  for (const [id, call] of pendingCalls) {
671
756
  if (call.name !== name)
@@ -699,7 +784,7 @@ function displayUserTextFromHistoryEntry(entry) {
699
784
  .slice(contentStart, contentEnd === -1 ? text.length : contentEnd)
700
785
  .trim();
701
786
  }
702
- function buildTranscriptFromSessionHistory(history) {
787
+ export function buildTranscriptFromSessionHistory(history) {
703
788
  const entries = [];
704
789
  const pendingCalls = new Map();
705
790
  for (const entry of history) {
@@ -745,6 +830,14 @@ function buildTranscriptFromSessionHistory(history) {
745
830
  }
746
831
  const text = textFromHistoryEntry(entry);
747
832
  if ((entry.role === 'model' || entry.role === 'assistant') && text) {
833
+ if (isTurnFailureMarker(text)) {
834
+ entries.push({
835
+ body: 'This request did not complete.',
836
+ kind: 'system',
837
+ title: 'Previous turn',
838
+ });
839
+ continue;
840
+ }
748
841
  entries.push({ body: text, kind: 'assistant', title: 'Response' });
749
842
  }
750
843
  }
@@ -814,11 +907,16 @@ function createShellStore(initialState) {
814
907
  },
815
908
  appendWorkingTool: (entry) => {
816
909
  const lastEntry = state.workingTools.at(-1);
817
- if (sameTranscriptDraft(lastEntry, entry))
910
+ const isDuplicate = sameTranscriptDraft(lastEntry, entry);
911
+ if (isDuplicate && state.commandLog.length === 0) {
818
912
  return;
913
+ }
819
914
  state = {
820
915
  ...state,
821
- workingTools: [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
916
+ commandLog: [],
917
+ workingTools: isDuplicate
918
+ ? state.workingTools
919
+ : [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
822
920
  };
823
921
  notify();
824
922
  },
@@ -865,9 +963,11 @@ function createInitialShellState(session, serverModels, debugUi) {
865
963
  activeTurnInput: '',
866
964
  activeTurnInputPreformatted: false,
867
965
  agentMode: session.agentMode,
966
+ analyzingImages: 0,
868
967
  approvalCursor: getDefaultApprovalCursor(),
869
968
  approvalPrompt: null,
870
969
  autoYes: session.autoYes,
970
+ backgroundJobs: [],
871
971
  busy: false,
872
972
  busySince: null,
873
973
  clockNow: Date.now(),
@@ -879,6 +979,9 @@ function createInitialShellState(session, serverModels, debugUi) {
879
979
  exiting: false,
880
980
  input: '',
881
981
  maxToolSteps: session.maxToolSteps,
982
+ jobsPickerExpandedId: null,
983
+ jobsPickerIndex: 0,
984
+ jobsPickerOpen: false,
882
985
  modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
883
986
  modelPickerOpen: false,
884
987
  projectRoot: session.rootDir,
@@ -899,6 +1002,7 @@ function createInitialShellState(session, serverModels, debugUi) {
899
1002
  exitConfirmUntil: null,
900
1003
  thinkingTitle: '',
901
1004
  thinkingNotes: [],
1005
+ todos: listTodos(),
902
1006
  tokenUsage: formatClientTokenUsage(null),
903
1007
  transcript: [],
904
1008
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
@@ -951,6 +1055,14 @@ function thinkingNoteFromStatus(status) {
951
1055
  const note = text.slice('Thinking:'.length).trim();
952
1056
  return note ? note : null;
953
1057
  }
1058
+ function stripMarkdownEmphasis(text) {
1059
+ return text
1060
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
1061
+ .replace(/___(.+?)___/g, '$1')
1062
+ .replace(/\*\*(.+?)\*\*/g, '$1')
1063
+ .replace(/__(.+?)__/g, '$1')
1064
+ .replace(/(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)/g, '$1');
1065
+ }
954
1066
  function splitThinkingLines(text) {
955
1067
  return text
956
1068
  .split('\n')
@@ -959,13 +1071,13 @@ function splitThinkingLines(text) {
959
1071
  .flatMap((line) => line
960
1072
  .split(/(?<=[.!?])\s+(?=[A-Z0-9"'`])/)
961
1073
  .map((part) => part.trim())
962
- .filter(Boolean))
963
- .map((line) => truncate(line, 120));
1074
+ .filter(Boolean));
964
1075
  }
965
1076
  function thinkingPanelFromStatus(status) {
966
- const text = thinkingNoteFromStatus(status);
967
- if (!text)
1077
+ const rawText = thinkingNoteFromStatus(status);
1078
+ if (!rawText)
968
1079
  return null;
1080
+ const text = stripMarkdownEmphasis(rawText);
969
1081
  const rawLines = text
970
1082
  .split('\n')
971
1083
  .map((line) => line.trim())
@@ -974,12 +1086,12 @@ function thinkingPanelFromStatus(status) {
974
1086
  return null;
975
1087
  if (rawLines.length === 1 && rawLines[0].length <= 72) {
976
1088
  return {
977
- title: truncate(rawLines[0], 72),
1089
+ title: rawLines[0],
978
1090
  notes: [],
979
1091
  };
980
1092
  }
981
1093
  const title = rawLines.length > 1 && rawLines[0].length <= 72
982
- ? truncate(rawLines[0], 72)
1094
+ ? rawLines[0]
983
1095
  : 'Thinking';
984
1096
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
985
1097
  return {
@@ -997,7 +1109,10 @@ export function getInputCommandToken(input) {
997
1109
  if (!trimmed.startsWith('/'))
998
1110
  return '';
999
1111
  const firstSpaceIndex = trimmed.indexOf(' ');
1000
- return firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1112
+ const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1113
+ if (token.indexOf('/', 1) !== -1)
1114
+ return '';
1115
+ return token;
1001
1116
  }
1002
1117
  function shouldShowCommandPalette(state) {
1003
1118
  const trimmed = String(state.input ?? '').trim();
@@ -1006,7 +1121,8 @@ function shouldShowCommandPalette(state) {
1006
1121
  !state.modelPickerOpen &&
1007
1122
  !state.resumePickerOpen &&
1008
1123
  trimmed.startsWith('/') &&
1009
- !trimmed.includes(' '));
1124
+ !trimmed.includes(' ') &&
1125
+ getInputCommandToken(trimmed) !== '');
1010
1126
  }
1011
1127
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
1012
1128
  const currentShowsCommands = shouldShowCommandPalette(current);
@@ -1135,7 +1251,11 @@ export function buildModelPickerOptions(currentModelId, serverModels) {
1135
1251
  return serverModels.map((model) => ({
1136
1252
  id: model.id,
1137
1253
  label: model.label,
1138
- meta: model.id === currentModelId ? 'current' : '',
1254
+ publicId: model.id,
1255
+ costRating: model.costRating,
1256
+ current: model.id === currentModelId,
1257
+ disabled: false,
1258
+ note: model.description,
1139
1259
  }));
1140
1260
  }
1141
1261
  function getDefaultModelPickerIndex(currentModelId, serverModels) {
@@ -1163,22 +1283,23 @@ function appendCommandLog(state, text) {
1163
1283
  };
1164
1284
  }
1165
1285
  async function saveSessionBoth({ serverSessionClient, session, }) {
1286
+ if (!sessionHasUserMessage(session))
1287
+ return;
1166
1288
  saveSessionState(session);
1167
1289
  await serverSessionClient.save(session);
1168
1290
  }
1169
- function shouldUseRatatuiShell() {
1170
- if (process.env.THEGITAI_PLAIN === '1')
1171
- return false;
1172
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1173
- }
1174
- export function shouldUseClientRatatuiShell() {
1175
- return shouldUseRatatuiShell();
1176
- }
1177
1291
  export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, projectIndex, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1178
- if (!shouldUseRatatuiShell()) {
1179
- throw new Error('Client TUI requires an interactive terminal.');
1292
+ if (process.stdin.isTTY !== true) {
1293
+ throw new Error('stdin is not a terminal');
1294
+ }
1295
+ if (process.stdout.isTTY !== true) {
1296
+ throw new Error('stdout is not a terminal');
1180
1297
  }
1298
+ let fatalError = null;
1181
1299
  await withTuiMode(async () => {
1300
+ setBackgroundJobSession(session.sessionId);
1301
+ setScratchSession(session.sessionId);
1302
+ setTodoSession(session.sessionId);
1182
1303
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1183
1304
  store.replaceTranscript(createSessionTranscript(session));
1184
1305
  let currentServerModels = serverModels;
@@ -1189,6 +1310,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1189
1310
  let cleanupSudoPasswordPrompt = null;
1190
1311
  let sudoPasswordBuffer = '';
1191
1312
  const bridge = createRatatuiBridge();
1313
+ captureTerminalWrites();
1192
1314
  const { handleShellKeyEvent } = await import('./tui/shell-input.js');
1193
1315
  let terminalCols = 80;
1194
1316
  let terminalRows = 24;
@@ -1201,6 +1323,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1201
1323
  let latestUsageSummary = null;
1202
1324
  let pendingTurnEntries = [];
1203
1325
  let activeTurnAbort = null;
1326
+ let newConversationInFlight = false;
1327
+ let todosTouchedThisTurn = false;
1328
+ const syncTodosState = () => {
1329
+ store.update((current) => ({ ...current, todos: listTodos() }));
1330
+ };
1204
1331
  let activeTurnGeneration = 0;
1205
1332
  let exitCtrlCArmed = false;
1206
1333
  let exitCtrlCTimer = null;
@@ -1229,14 +1356,25 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1229
1356
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1230
1357
  }, 2500);
1231
1358
  };
1359
+ const terminalTitle = createTerminalTitleController({
1360
+ write: (title) => bridge.setTitle(title),
1361
+ });
1362
+ const syncTerminalTitle = () => {
1363
+ const state = store.getState();
1364
+ terminalTitle.sync({
1365
+ awaitingReview: Boolean(state.approvalPrompt || state.sudoPrompt),
1366
+ busy: state.busy,
1367
+ });
1368
+ };
1232
1369
  const renderCurrentFrame = () => {
1233
1370
  if (!tuiReady)
1234
1371
  return;
1235
1372
  const state = store.getState();
1373
+ syncTerminalTitle();
1236
1374
  const elapsedSeconds = state.busySince
1237
1375
  ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1238
1376
  : 0;
1239
- bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds));
1377
+ bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1240
1378
  };
1241
1379
  const remountTui = async () => {
1242
1380
  if (remountPromise) {
@@ -1387,7 +1525,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1387
1525
  }));
1388
1526
  };
1389
1527
  const cancelActiveTurn = () => {
1390
- if (!store.getState().busy)
1528
+ if (!store.getState().busy || newConversationInFlight)
1391
1529
  return;
1392
1530
  disarmExitConfirm();
1393
1531
  dismissPendingApproval();
@@ -1396,9 +1534,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1396
1534
  activeTurnAbort?.abort();
1397
1535
  activeTurnAbort = null;
1398
1536
  cancelActiveCommand();
1399
- // Ctrl+C with a queued message: recall it into the composer for editing
1400
- // rather than auto-submitting it against the cancelled turn. (Esc with a
1401
- // queued message clears the slot in shell-input without cancelling.)
1402
1537
  const queued = store.getState().queuedMessage;
1403
1538
  const cancelledEntries = [
1404
1539
  ...takePendingTurnEntries(),
@@ -1434,14 +1569,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1434
1569
  scheduleLiveFrameRemount();
1435
1570
  void remountTui();
1436
1571
  };
1437
- const saveActiveSession = async () => {
1438
- try {
1439
- await saveSessionBoth({ serverSessionClient, session });
1440
- }
1441
- catch (error) {
1442
- appendError(`Session save failed: ${error.message}`);
1443
- }
1444
- };
1445
1572
  const syncShellStateFromSession = () => {
1446
1573
  store.update((current) => ({
1447
1574
  ...current,
@@ -1521,6 +1648,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1521
1648
  dismissPendingApproval();
1522
1649
  dismissPendingSudoPassword();
1523
1650
  exiting = true;
1651
+ killAllBackgroundJobs();
1524
1652
  store.update((current) => ({
1525
1653
  ...current,
1526
1654
  exiting: true,
@@ -1528,9 +1656,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1528
1656
  status: 'Exiting...',
1529
1657
  }));
1530
1658
  void bridge.close().then(() => {
1659
+ releaseTerminalWrites();
1531
1660
  resolveDone?.();
1532
1661
  });
1533
1662
  };
1663
+ const exitForAuthenticationError = (error) => {
1664
+ if (!isAuthenticationError(error))
1665
+ return false;
1666
+ clearCliAuthConfig(session.env);
1667
+ if (sessionHasUserMessage(session)) {
1668
+ saveSessionState(session);
1669
+ }
1670
+ fatalError = new Error(authenticationErrorMessage(error));
1671
+ requestExit();
1672
+ return true;
1673
+ };
1674
+ const saveActiveSession = async () => {
1675
+ try {
1676
+ await saveSessionBoth({ serverSessionClient, session });
1677
+ return true;
1678
+ }
1679
+ catch (error) {
1680
+ if (exitForAuthenticationError(error))
1681
+ return false;
1682
+ appendError(`Session save failed: ${error.message}`);
1683
+ return true;
1684
+ }
1685
+ };
1686
+ const saveActiveSessionOrAbort = async () => {
1687
+ try {
1688
+ await saveSessionBoth({ serverSessionClient, session });
1689
+ return true;
1690
+ }
1691
+ catch (error) {
1692
+ if (exitForAuthenticationError(error))
1693
+ return false;
1694
+ appendError(`Session save failed: ${error.message}`);
1695
+ return false;
1696
+ }
1697
+ };
1534
1698
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1535
1699
  if (exiting || signal?.aborted) {
1536
1700
  resolve(null);
@@ -1667,7 +1831,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1667
1831
  selectedModelId: selected,
1668
1832
  serverModels: fresh,
1669
1833
  });
1670
- await saveActiveSession();
1834
+ if (!(await saveActiveSession()))
1835
+ return;
1671
1836
  syncShellStateFromSession();
1672
1837
  appendStaticEntry({
1673
1838
  body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
@@ -1675,6 +1840,87 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1675
1840
  title: 'Model',
1676
1841
  });
1677
1842
  };
1843
+ const openJobsPicker = () => {
1844
+ syncBackgroundJobsState();
1845
+ store.update((current) => ({
1846
+ ...current,
1847
+ commandCursor: 0,
1848
+ cursor: 0,
1849
+ input: '',
1850
+ jobsPickerExpandedId: null,
1851
+ jobsPickerIndex: 0,
1852
+ jobsPickerOpen: true,
1853
+ status: 'Background jobs',
1854
+ }));
1855
+ };
1856
+ const appendJobOutputEntry = async (jobId) => {
1857
+ const id = jobId.trim();
1858
+ await collectBackgroundJobUiOutputMutations({
1859
+ session,
1860
+ projectIndex,
1861
+ jobId: id,
1862
+ });
1863
+ const job = listBackgroundJobs().find((candidate) => candidate.id === id);
1864
+ const buffered = getJobBufferedOutput(id);
1865
+ if (!job || !buffered) {
1866
+ appendError(`Unknown background job id: ${id}`);
1867
+ return;
1868
+ }
1869
+ const dropped = buffered.droppedChars > 0
1870
+ ? `... (${buffered.droppedChars} chars of older output dropped) ...\n`
1871
+ : '';
1872
+ appendStaticEntry({
1873
+ body: `${dropped}${buffered.output || '(no output captured)'}`,
1874
+ kind: 'system',
1875
+ preformatted: true,
1876
+ title: `${job.id} output — ${truncate(job.command, 80)}`,
1877
+ });
1878
+ };
1879
+ const selectedJobsPickerId = () => {
1880
+ const current = store.getState();
1881
+ if (!current.backgroundJobs.length)
1882
+ return null;
1883
+ const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
1884
+ return current.backgroundJobs[index]?.id ?? null;
1885
+ };
1886
+ const handleJobsPickerOutput = async () => {
1887
+ const jobId = selectedJobsPickerId();
1888
+ if (!jobId)
1889
+ return;
1890
+ await collectBackgroundJobUiOutputMutations({
1891
+ session,
1892
+ projectIndex,
1893
+ jobId,
1894
+ });
1895
+ syncBackgroundJobsState();
1896
+ store.update((current) => ({
1897
+ ...current,
1898
+ jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
1899
+ }));
1900
+ };
1901
+ const handleJobsPickerKill = async () => {
1902
+ const jobId = selectedJobsPickerId();
1903
+ if (!jobId)
1904
+ return;
1905
+ const killed = await killBackgroundJob(jobId);
1906
+ await collectBackgroundJobUiKillMutations({
1907
+ session,
1908
+ projectIndex,
1909
+ jobId,
1910
+ result: killed,
1911
+ });
1912
+ syncBackgroundJobsState();
1913
+ if (!killed.ok) {
1914
+ appendError(killed.error ?? 'Background job kill failed.');
1915
+ }
1916
+ else if (killed.alreadyFinished) {
1917
+ appendStaticEntry({
1918
+ body: `${jobId} had already finished.`,
1919
+ kind: 'system',
1920
+ title: 'Background jobs',
1921
+ });
1922
+ }
1923
+ };
1678
1924
  const handleInlineModelSelection = async () => {
1679
1925
  const current = store.getState();
1680
1926
  const options = buildModelPickerOptions(current.currentModelId, current.serverModels);
@@ -1705,6 +1951,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1705
1951
  await switchToModel(selected.id);
1706
1952
  }
1707
1953
  catch (error) {
1954
+ if (exitForAuthenticationError(error))
1955
+ return;
1708
1956
  appendError(error.message);
1709
1957
  }
1710
1958
  };
@@ -1761,7 +2009,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1761
2009
  try {
1762
2010
  const snapshot = await loadInteractiveSession(selected.id);
1763
2011
  applySessionSnapshot(session, snapshot);
1764
- await saveActiveSession();
2012
+ setBackgroundJobSession(session.sessionId);
2013
+ setScratchSession(session.sessionId);
2014
+ syncBackgroundJobsState();
2015
+ setTodoSession(session.sessionId);
2016
+ syncTodosState();
1765
2017
  syncShellStateFromSession();
1766
2018
  store.update((next) => ({
1767
2019
  ...next,
@@ -1784,6 +2036,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1784
2036
  await remountTui();
1785
2037
  }
1786
2038
  catch (error) {
2039
+ if (exitForAuthenticationError(error))
2040
+ return;
1787
2041
  store.update((next) => ({
1788
2042
  ...next,
1789
2043
  busy: false,
@@ -1802,8 +2056,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1802
2056
  if (!queued || store.getState().busy || exiting) {
1803
2057
  return;
1804
2058
  }
1805
- // Rehydrate attachments/chunks before submit: handleSubmit expands
1806
- // pastedChunks from the store and the turn picks up imageAttachments.
1807
2059
  store.update((current) => ({
1808
2060
  ...current,
1809
2061
  imageAttachments: queued.imageAttachments,
@@ -1830,9 +2082,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1830
2082
  });
1831
2083
  return;
1832
2084
  }
1833
- // Snapshot the raw (placeholder) body plus chunks/images so recall and
1834
- // flush round-trip the collapsed paste + attachments. Hold at most one;
1835
- // a second enqueue replaces the slot.
1836
2085
  const pending = store.getState();
1837
2086
  const snapshot = {
1838
2087
  body: String(rawInput ?? ''),
@@ -1887,6 +2136,46 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1887
2136
  });
1888
2137
  return;
1889
2138
  }
2139
+ if (input === '/jobs' || input.startsWith('/jobs ')) {
2140
+ const jobsArgs = input.slice('/jobs'.length).trim();
2141
+ if (!jobsArgs) {
2142
+ openJobsPicker();
2143
+ return;
2144
+ }
2145
+ const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
2146
+ if (killMatch) {
2147
+ const jobId = killMatch[1];
2148
+ const killed = await killBackgroundJob(jobId);
2149
+ await collectBackgroundJobUiKillMutations({
2150
+ session,
2151
+ projectIndex,
2152
+ jobId,
2153
+ result: killed,
2154
+ });
2155
+ if (!killed.ok) {
2156
+ appendError(killed.error ?? 'Background job kill failed.');
2157
+ }
2158
+ else if (killed.alreadyFinished) {
2159
+ appendStaticEntry({
2160
+ body: `${jobId} had already finished.`,
2161
+ kind: 'system',
2162
+ title: 'Background jobs',
2163
+ });
2164
+ }
2165
+ return;
2166
+ }
2167
+ const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
2168
+ if (outputMatch) {
2169
+ await appendJobOutputEntry(outputMatch[1]);
2170
+ return;
2171
+ }
2172
+ appendStaticEntry({
2173
+ body: 'Usage: /jobs — open the jobs picker · /jobs output <id> — full output · /jobs kill <id> — kill one',
2174
+ kind: 'system',
2175
+ title: 'Background jobs',
2176
+ });
2177
+ return;
2178
+ }
1890
2179
  if (input === '/usage') {
1891
2180
  store.update((current) => ({
1892
2181
  ...current,
@@ -1901,14 +2190,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1901
2190
  });
1902
2191
  }
1903
2192
  catch (error) {
2193
+ if (exitForAuthenticationError(error))
2194
+ return;
1904
2195
  appendError(error.message);
1905
2196
  }
1906
2197
  finally {
1907
- store.update((current) => ({
1908
- ...current,
1909
- busy: false,
1910
- status: 'Ready',
1911
- }));
2198
+ if (!exiting) {
2199
+ store.update((current) => ({
2200
+ ...current,
2201
+ busy: false,
2202
+ status: 'Ready',
2203
+ }));
2204
+ }
1912
2205
  }
1913
2206
  return;
1914
2207
  }
@@ -1922,14 +2215,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1922
2215
  await openResumePicker();
1923
2216
  }
1924
2217
  catch (error) {
2218
+ if (exitForAuthenticationError(error))
2219
+ return;
1925
2220
  appendError(error.message);
1926
2221
  }
1927
2222
  finally {
1928
- store.update((current) => ({
1929
- ...current,
1930
- busy: false,
1931
- status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
1932
- }));
2223
+ if (!exiting) {
2224
+ store.update((current) => ({
2225
+ ...current,
2226
+ busy: false,
2227
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2228
+ }));
2229
+ }
1933
2230
  }
1934
2231
  return;
1935
2232
  }
@@ -1949,31 +2246,68 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1949
2246
  }
1950
2247
  }
1951
2248
  catch (error) {
2249
+ if (exitForAuthenticationError(error))
2250
+ return;
1952
2251
  appendError(error.message);
1953
2252
  }
1954
2253
  finally {
2254
+ if (!exiting) {
2255
+ store.update((current) => ({
2256
+ ...current,
2257
+ busy: false,
2258
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2259
+ }));
2260
+ }
2261
+ }
2262
+ return;
2263
+ }
2264
+ if (input === '/new') {
2265
+ newConversationInFlight = true;
2266
+ store.update((current) => ({
2267
+ ...current,
2268
+ busy: true,
2269
+ busySince: Date.now(),
2270
+ imageAttachments: [],
2271
+ queuedMessage: null,
2272
+ status: 'Starting a new conversation...',
2273
+ }));
2274
+ if (!(await saveActiveSessionOrAbort())) {
2275
+ newConversationInFlight = false;
1955
2276
  store.update((current) => ({
1956
2277
  ...current,
1957
2278
  busy: false,
1958
- status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2279
+ busySince: null,
2280
+ status: 'Ready',
1959
2281
  }));
2282
+ await flushQueuedMessage();
2283
+ return;
1960
2284
  }
1961
- return;
1962
- }
1963
- if (input === '/clear') {
1964
- clearConversation(session);
2285
+ startNewConversation(session);
2286
+ setBackgroundJobSession(session.sessionId);
2287
+ setScratchSession(session.sessionId);
2288
+ setTodoSession(session.sessionId);
2289
+ clearTodos();
2290
+ syncBackgroundJobsState();
2291
+ syncTodosState();
1965
2292
  latestUsageSummary = null;
1966
- await saveActiveSession();
1967
2293
  store.replaceTranscript([
1968
2294
  {
1969
- body: 'Conversation cleared.',
2295
+ body: 'Started a new conversation. The previous session remains saved.',
1970
2296
  kind: 'system',
1971
- title: 'System',
2297
+ title: 'Session',
1972
2298
  },
1973
2299
  ]);
2300
+ await saveActiveSession();
1974
2301
  syncShellStateFromSession();
1975
- store.update((current) => ({ ...current, queuedMessage: null }));
2302
+ newConversationInFlight = false;
2303
+ store.update((current) => ({
2304
+ ...current,
2305
+ busy: false,
2306
+ busySince: null,
2307
+ status: 'Ready',
2308
+ }));
1976
2309
  await remountTui();
2310
+ await flushQueuedMessage();
1977
2311
  return;
1978
2312
  }
1979
2313
  latestUsageSummary = null;
@@ -1983,6 +2317,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1983
2317
  const turnAbort = new AbortController();
1984
2318
  activeTurnAbort = turnAbort;
1985
2319
  lastTurnStartedAt = turnStartedAt;
2320
+ todosTouchedThisTurn = false;
2321
+ clearTodos();
2322
+ syncTodosState();
1986
2323
  const userEntry = {
1987
2324
  body: input,
1988
2325
  kind: 'user',
@@ -1995,6 +2332,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1995
2332
  ...current,
1996
2333
  activeTurnInput: input,
1997
2334
  activeTurnInputPreformatted: preformatted,
2335
+ analyzingImages: 0,
1998
2336
  busy: true,
1999
2337
  busySince: turnStartedAt,
2000
2338
  clockNow: turnStartedAt,
@@ -2019,7 +2357,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2019
2357
  return;
2020
2358
  latestUsageSummary = result.usageSummary ?? null;
2021
2359
  const turnEntries = takePendingTurnEntries();
2022
- await saveActiveSession();
2360
+ if (!(await saveActiveSession()))
2361
+ return;
2023
2362
  syncShellStateFromSession();
2024
2363
  store.update((current) => ({
2025
2364
  ...current,
@@ -2037,6 +2376,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2037
2376
  await appendSettledTurnEntries(turnEntries, result.text ?? '', () => turnGeneration === activeTurnGeneration && !turnAbort.signal.aborted);
2038
2377
  if (turnGeneration !== activeTurnGeneration)
2039
2378
  return;
2379
+ if (todosTouchedThisTurn) {
2380
+ todosTouchedThisTurn = false;
2381
+ const todoItems = listTodos();
2382
+ if (todoItems.length > 0) {
2383
+ appendStaticEntry({
2384
+ body: '',
2385
+ kind: 'system',
2386
+ title: `To-dos · ${formatTodoProgress(todoItems)}`,
2387
+ todoList: todoItems,
2388
+ });
2389
+ if (todoItems.every((item) => item.status === 'completed')) {
2390
+ clearTodos();
2391
+ syncTodosState();
2392
+ }
2393
+ }
2394
+ }
2040
2395
  store.update((current) => ({
2041
2396
  ...current,
2042
2397
  busy: false,
@@ -2054,6 +2409,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2054
2409
  return;
2055
2410
  }
2056
2411
  const cancelled = isTurnCancelledError(error);
2412
+ if (exitForAuthenticationError(error))
2413
+ return;
2057
2414
  store.update((current) => ({
2058
2415
  ...current,
2059
2416
  activeTurnInput: '',
@@ -2078,15 +2435,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2078
2435
  title: 'System',
2079
2436
  },
2080
2437
  ]);
2081
- await saveActiveSession();
2438
+ if (!(await saveActiveSession()))
2439
+ return;
2082
2440
  }
2083
2441
  else {
2084
2442
  appendStaticEntries(turnEntries);
2085
2443
  appendError(error.message);
2086
2444
  }
2087
2445
  await remountTui();
2088
- // A cancelled turn never auto-submits the queue (Ctrl+C recalls it via
2089
- // cancelActiveTurn); only a genuine error flushes a pending message.
2090
2446
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2091
2447
  await flushQueuedMessage();
2092
2448
  }
@@ -2100,6 +2456,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2100
2456
  }
2101
2457
  }
2102
2458
  };
2459
+ session.onImageAnalysis = (activeImageCount) => {
2460
+ store.update((current) => ({
2461
+ ...current,
2462
+ analyzingImages: Math.max(0, activeImageCount),
2463
+ }));
2464
+ };
2103
2465
  session.onStatus = (message) => {
2104
2466
  const panel = thinkingPanelFromStatus(message);
2105
2467
  store.update((current) => ({
@@ -2119,6 +2481,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2119
2481
  };
2120
2482
  session.onToolEvent = (event) => {
2121
2483
  syncShellStateFromSession();
2484
+ if (event.call.name === 'update_todos') {
2485
+ syncTodosState();
2486
+ if (event.result?.ok === true) {
2487
+ todosTouchedThisTurn = true;
2488
+ return;
2489
+ }
2490
+ }
2122
2491
  if (isFileChangeTool(event.call.name)) {
2123
2492
  const entry = buildFileChangeEntry(event);
2124
2493
  store.appendWorkingTool(entry);
@@ -2127,6 +2496,52 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2127
2496
  }
2128
2497
  store.appendWorkingTool(buildWorkingToolEntry(event));
2129
2498
  };
2499
+ const jobDetailLines = (jobId) => {
2500
+ const buffered = getJobBufferedOutput(jobId);
2501
+ if (!buffered)
2502
+ return [];
2503
+ const lines = buffered.output
2504
+ .replace(/\r\n/g, '\n')
2505
+ .replace(/\r/g, '\n')
2506
+ .split('\n')
2507
+ .filter((line) => line.trim().length > 0);
2508
+ const visible = lines.slice(-20);
2509
+ return buffered.droppedChars > 0
2510
+ ? [
2511
+ `... (${buffered.droppedChars} chars of older output dropped) ...`,
2512
+ ...visible,
2513
+ ]
2514
+ : visible;
2515
+ };
2516
+ const syncBackgroundJobsState = () => {
2517
+ const jobsForDisplay = listBackgroundJobs().map((job) => {
2518
+ const preview = getJobOutputPreview(job.id, 3);
2519
+ return {
2520
+ id: job.id,
2521
+ command: job.command,
2522
+ status: job.status,
2523
+ exitCode: job.exitCode,
2524
+ startedAt: job.startedAt,
2525
+ endedAt: job.endedAt,
2526
+ firstOutputLine: preview?.firstLine ?? '',
2527
+ tailLines: preview?.tailLines ?? [],
2528
+ detailLines: jobDetailLines(job.id),
2529
+ };
2530
+ });
2531
+ store.update((current) => ({
2532
+ ...current,
2533
+ backgroundJobs: jobsForDisplay,
2534
+ jobsPickerExpandedId: jobsForDisplay.some((job) => job.id === current.jobsPickerExpandedId)
2535
+ ? current.jobsPickerExpandedId
2536
+ : null,
2537
+ }));
2538
+ };
2539
+ setBackgroundJobUpdateHook((snapshot) => {
2540
+ syncBackgroundJobsState();
2541
+ if (snapshot.status !== 'running') {
2542
+ appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2543
+ }
2544
+ });
2130
2545
  projectIndex.onStatus = session.onStatus;
2131
2546
  projectIndex.onContextLog = session.onContextLog;
2132
2547
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
@@ -2186,6 +2601,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2186
2601
  onResumeSession: handleInlineResumeSelection,
2187
2602
  onSelectionCopy: handleAppSelectionCopy,
2188
2603
  onSelectModel: handleInlineModelSelection,
2604
+ onJobsPickerOutput: handleJobsPickerOutput,
2605
+ onJobsPickerKill: handleJobsPickerKill,
2189
2606
  onSudoPasswordInput: handleSudoPasswordInput,
2190
2607
  onSubmit: handleSubmit,
2191
2608
  };
@@ -2199,7 +2616,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2199
2616
  }
2200
2617
  }, 120);
2201
2618
  const clockTimer = setInterval(() => {
2202
- if (store.getState().busy) {
2619
+ if (hasRunningBackgroundJobs()) {
2620
+ syncBackgroundJobsState();
2621
+ }
2622
+ if (store.getState().busy || hasRunningBackgroundJobs()) {
2203
2623
  renderCurrentFrame();
2204
2624
  }
2205
2625
  }, 1000);
@@ -2243,8 +2663,17 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2243
2663
  clearInterval(clockTimer);
2244
2664
  unsubscribe();
2245
2665
  clearLiveFrameRemountTimer();
2666
+ terminalTitle.dispose();
2667
+ killAllBackgroundJobs();
2668
+ setBackgroundJobSession(null);
2669
+ setScratchSession(null);
2670
+ setTodoSession(null);
2671
+ setBackgroundJobUpdateHook(null);
2246
2672
  await bridge.close();
2673
+ releaseTerminalWrites();
2247
2674
  setCommandOutputHook(null);
2248
2675
  }
2249
2676
  });
2677
+ if (fatalError)
2678
+ throw fatalError;
2250
2679
  }