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

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 (56) hide show
  1. package/README.md +36 -2
  2. package/dist/bin/ai.js +138 -18
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +3 -3
  6. package/dist/src/api/browser-login.js +0 -16
  7. package/dist/src/api/chat.js +59 -11
  8. package/dist/src/api/http.js +49 -1
  9. package/dist/src/api/models.js +26 -20
  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 +11 -6
  19. package/dist/src/markdown-renderer.js +1 -1
  20. package/dist/src/patcher.js +97 -12
  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 +0 -1
  27. package/dist/src/todo-list.js +106 -0
  28. package/dist/src/tool-executor.js +194 -21
  29. package/dist/src/tools/delete-file.js +23 -5
  30. package/dist/src/tools/index.js +6 -0
  31. package/dist/src/tools/patch-file.js +33 -7
  32. package/dist/src/tools/path-suggest.js +81 -8
  33. package/dist/src/tools/read-document.js +2 -2
  34. package/dist/src/tools/read-file.js +17 -8
  35. package/dist/src/tools/replace-document-text.js +10 -12
  36. package/dist/src/tools/restore-checkpoint.js +1 -1
  37. package/dist/src/tools/run-command.js +109 -24
  38. package/dist/src/tools/run-node-script.js +27 -5
  39. package/dist/src/tools/shell-job-kill.js +48 -0
  40. package/dist/src/tools/shell-job-output.js +51 -0
  41. package/dist/src/tools/str-replace.js +33 -7
  42. package/dist/src/tools/undo-edit.js +1 -1
  43. package/dist/src/tools/update-todos.js +27 -0
  44. package/dist/src/tools/write-file.js +26 -6
  45. package/dist/src/tree-sitter-runtime.js +8 -1
  46. package/dist/src/ui/repl.js +342 -23
  47. package/dist/src/ui/tui/bridge.js +0 -4
  48. package/dist/src/ui/tui/build-frame.js +220 -24
  49. package/dist/src/ui/tui/shell-input.js +33 -4
  50. package/dist/src/ui/tui/terminal-title.js +81 -0
  51. package/dist/src/version.js +0 -6
  52. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  53. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  54. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  55. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  56. package/package.json +14 -15
@@ -1,11 +1,16 @@
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';
3
4
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
4
5
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
5
6
  import { chat, models } from '../api/index.js';
6
7
  import { isTurnCancelledError } from '../api/chat.js';
8
+ import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
9
+ import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
10
+ import { setScratchSession } from '../scratch-dir.js';
7
11
  import { cancelActiveCommand } from '../executor.js';
8
12
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
13
+ import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
9
14
  import { clearConversation, } from '../session.js';
10
15
  import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
11
16
  import { truncate } from '../utils.js';
@@ -102,6 +107,10 @@ export const SLASH_COMMANDS = [
102
107
  command: '/resume',
103
108
  description: 'Open the session picker',
104
109
  },
110
+ {
111
+ command: '/jobs',
112
+ description: 'Background jobs: pick to view output or kill',
113
+ },
105
114
  {
106
115
  command: '/clear',
107
116
  description: 'Clear conversation history',
@@ -550,6 +559,31 @@ function buildFileChangeEntry(event) {
550
559
  title: skipped ? `${verb} skipped: ${filePath}` : `${verb} failed: ${filePath}`,
551
560
  };
552
561
  }
562
+ if (result?.scratch === true) {
563
+ const scratchName = truncate(filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? filePath, 72);
564
+ if (call.name === 'delete_file') {
565
+ return {
566
+ body: '',
567
+ filePath,
568
+ kind: 'tool',
569
+ title: result?.deleted === true
570
+ ? `Removed scratch file: ${scratchName}`
571
+ : `Scratch delete skipped: ${scratchName}`,
572
+ };
573
+ }
574
+ const content = typeof call.args?.content === 'string' ? call.args.content : '';
575
+ const lineSummary = call.name === 'write_file' && content
576
+ ? ` (${splitDiffLines(content).length} lines)`
577
+ : '';
578
+ return {
579
+ body: '',
580
+ filePath,
581
+ kind: 'tool',
582
+ title: call.name === 'write_file'
583
+ ? `Wrote scratch file: ${scratchName}${lineSummary}`
584
+ : `Edited scratch file: ${scratchName}`,
585
+ };
586
+ }
553
587
  if (call.name === 'undo_edit') {
554
588
  const dryRun = result?.dryRun === true ||
555
589
  result?.dry_run === true ||
@@ -643,12 +677,33 @@ function buildWorkingToolEntry(event) {
643
677
  : '';
644
678
  if (call.name === 'run_command') {
645
679
  const command = String(call.args?.command ?? result?.command ?? '').trim();
680
+ if (result?.backgrounded === true && result?.jobId) {
681
+ return {
682
+ body: `$ ${truncate(command, 180)}\nBackground job ${String(result.jobId)} started.${error}`,
683
+ kind: result?.ok === true ? 'tool' : 'error',
684
+ preformatted: true,
685
+ title: 'Shell',
686
+ };
687
+ }
646
688
  return {
647
689
  body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
648
690
  kind: result?.ok === true ? 'tool' : 'error',
649
691
  title: 'Shell',
650
692
  };
651
693
  }
694
+ if (call.name === 'shell_job_output' || call.name === 'shell_job_kill') {
695
+ const jobId = String(result?.jobId ?? call.args?.job_id ?? '').trim();
696
+ const status = String(result?.status ?? '').trim();
697
+ const exitCode = result?.exitCode;
698
+ const stateText = status
699
+ ? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
700
+ : formatToolResultState(result);
701
+ return {
702
+ body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
703
+ kind: result?.ok === true ? 'tool' : 'error',
704
+ title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
705
+ };
706
+ }
652
707
  if (call.name === 'run_node_script') {
653
708
  return {
654
709
  body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
@@ -666,6 +721,32 @@ function buildWorkingToolEntry(event) {
666
721
  title: 'Tool',
667
722
  };
668
723
  }
724
+ function buildBackgroundJobNoticeEntry(snapshot) {
725
+ const command = truncate(snapshot.command, 120);
726
+ const ran = formatJobElapsed((snapshot.endedAt ?? Date.now()) - snapshot.startedAt);
727
+ if (snapshot.status === 'error') {
728
+ return {
729
+ body: `✖ ${snapshot.id} (${command}) failed to start`,
730
+ kind: 'error',
731
+ preformatted: true,
732
+ title: 'Background job',
733
+ };
734
+ }
735
+ if (snapshot.status === 'killed') {
736
+ return {
737
+ body: `■ ${snapshot.id} (${command}) killed · ran ${ran}`,
738
+ kind: 'system',
739
+ preformatted: true,
740
+ title: 'Background job',
741
+ };
742
+ }
743
+ return {
744
+ body: `${snapshot.exitCode === 0 ? '✓' : '✖'} ${snapshot.id} (${command}) exited (code ${snapshot.exitCode ?? 1}) · ran ${ran}`,
745
+ kind: snapshot.exitCode === 0 ? 'system' : 'error',
746
+ preformatted: true,
747
+ title: 'Background job',
748
+ };
749
+ }
669
750
  function findPendingToolCallByName(pendingCalls, name) {
670
751
  for (const [id, call] of pendingCalls) {
671
752
  if (call.name !== name)
@@ -814,11 +895,16 @@ function createShellStore(initialState) {
814
895
  },
815
896
  appendWorkingTool: (entry) => {
816
897
  const lastEntry = state.workingTools.at(-1);
817
- if (sameTranscriptDraft(lastEntry, entry))
898
+ const isDuplicate = sameTranscriptDraft(lastEntry, entry);
899
+ if (isDuplicate && state.commandLog.length === 0) {
818
900
  return;
901
+ }
819
902
  state = {
820
903
  ...state,
821
- workingTools: [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
904
+ commandLog: [],
905
+ workingTools: isDuplicate
906
+ ? state.workingTools
907
+ : [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
822
908
  };
823
909
  notify();
824
910
  },
@@ -868,6 +954,7 @@ function createInitialShellState(session, serverModels, debugUi) {
868
954
  approvalCursor: getDefaultApprovalCursor(),
869
955
  approvalPrompt: null,
870
956
  autoYes: session.autoYes,
957
+ backgroundJobs: [],
871
958
  busy: false,
872
959
  busySince: null,
873
960
  clockNow: Date.now(),
@@ -879,6 +966,9 @@ function createInitialShellState(session, serverModels, debugUi) {
879
966
  exiting: false,
880
967
  input: '',
881
968
  maxToolSteps: session.maxToolSteps,
969
+ jobsPickerExpandedId: null,
970
+ jobsPickerIndex: 0,
971
+ jobsPickerOpen: false,
882
972
  modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
883
973
  modelPickerOpen: false,
884
974
  projectRoot: session.rootDir,
@@ -899,6 +989,7 @@ function createInitialShellState(session, serverModels, debugUi) {
899
989
  exitConfirmUntil: null,
900
990
  thinkingTitle: '',
901
991
  thinkingNotes: [],
992
+ todos: listTodos(),
902
993
  tokenUsage: formatClientTokenUsage(null),
903
994
  transcript: [],
904
995
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
@@ -951,6 +1042,14 @@ function thinkingNoteFromStatus(status) {
951
1042
  const note = text.slice('Thinking:'.length).trim();
952
1043
  return note ? note : null;
953
1044
  }
1045
+ function stripMarkdownEmphasis(text) {
1046
+ return text
1047
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
1048
+ .replace(/___(.+?)___/g, '$1')
1049
+ .replace(/\*\*(.+?)\*\*/g, '$1')
1050
+ .replace(/__(.+?)__/g, '$1')
1051
+ .replace(/(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)/g, '$1');
1052
+ }
954
1053
  function splitThinkingLines(text) {
955
1054
  return text
956
1055
  .split('\n')
@@ -959,13 +1058,13 @@ function splitThinkingLines(text) {
959
1058
  .flatMap((line) => line
960
1059
  .split(/(?<=[.!?])\s+(?=[A-Z0-9"'`])/)
961
1060
  .map((part) => part.trim())
962
- .filter(Boolean))
963
- .map((line) => truncate(line, 120));
1061
+ .filter(Boolean));
964
1062
  }
965
1063
  function thinkingPanelFromStatus(status) {
966
- const text = thinkingNoteFromStatus(status);
967
- if (!text)
1064
+ const rawText = thinkingNoteFromStatus(status);
1065
+ if (!rawText)
968
1066
  return null;
1067
+ const text = stripMarkdownEmphasis(rawText);
969
1068
  const rawLines = text
970
1069
  .split('\n')
971
1070
  .map((line) => line.trim())
@@ -974,12 +1073,12 @@ function thinkingPanelFromStatus(status) {
974
1073
  return null;
975
1074
  if (rawLines.length === 1 && rawLines[0].length <= 72) {
976
1075
  return {
977
- title: truncate(rawLines[0], 72),
1076
+ title: rawLines[0],
978
1077
  notes: [],
979
1078
  };
980
1079
  }
981
1080
  const title = rawLines.length > 1 && rawLines[0].length <= 72
982
- ? truncate(rawLines[0], 72)
1081
+ ? rawLines[0]
983
1082
  : 'Thinking';
984
1083
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
985
1084
  return {
@@ -997,7 +1096,10 @@ export function getInputCommandToken(input) {
997
1096
  if (!trimmed.startsWith('/'))
998
1097
  return '';
999
1098
  const firstSpaceIndex = trimmed.indexOf(' ');
1000
- return firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1099
+ const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1100
+ if (token.indexOf('/', 1) !== -1)
1101
+ return '';
1102
+ return token;
1001
1103
  }
1002
1104
  function shouldShowCommandPalette(state) {
1003
1105
  const trimmed = String(state.input ?? '').trim();
@@ -1006,7 +1108,8 @@ function shouldShowCommandPalette(state) {
1006
1108
  !state.modelPickerOpen &&
1007
1109
  !state.resumePickerOpen &&
1008
1110
  trimmed.startsWith('/') &&
1009
- !trimmed.includes(' '));
1111
+ !trimmed.includes(' ') &&
1112
+ getInputCommandToken(trimmed) !== '');
1010
1113
  }
1011
1114
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
1012
1115
  const currentShowsCommands = shouldShowCommandPalette(current);
@@ -1179,6 +1282,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1179
1282
  throw new Error('Client TUI requires an interactive terminal.');
1180
1283
  }
1181
1284
  await withTuiMode(async () => {
1285
+ setBackgroundJobSession(session.sessionId);
1286
+ setScratchSession(session.sessionId);
1287
+ setTodoSession(session.sessionId);
1182
1288
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1183
1289
  store.replaceTranscript(createSessionTranscript(session));
1184
1290
  let currentServerModels = serverModels;
@@ -1201,6 +1307,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1201
1307
  let latestUsageSummary = null;
1202
1308
  let pendingTurnEntries = [];
1203
1309
  let activeTurnAbort = null;
1310
+ let todosTouchedThisTurn = false;
1311
+ const syncTodosState = () => {
1312
+ store.update((current) => ({ ...current, todos: listTodos() }));
1313
+ };
1204
1314
  let activeTurnGeneration = 0;
1205
1315
  let exitCtrlCArmed = false;
1206
1316
  let exitCtrlCTimer = null;
@@ -1229,14 +1339,23 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1229
1339
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1230
1340
  }, 2500);
1231
1341
  };
1342
+ const terminalTitle = createTerminalTitleController();
1343
+ const syncTerminalTitle = () => {
1344
+ const state = store.getState();
1345
+ terminalTitle.sync({
1346
+ awaitingReview: Boolean(state.approvalPrompt || state.sudoPrompt),
1347
+ busy: state.busy,
1348
+ });
1349
+ };
1232
1350
  const renderCurrentFrame = () => {
1233
1351
  if (!tuiReady)
1234
1352
  return;
1235
1353
  const state = store.getState();
1354
+ syncTerminalTitle();
1236
1355
  const elapsedSeconds = state.busySince
1237
1356
  ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1238
1357
  : 0;
1239
- bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds));
1358
+ bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1240
1359
  };
1241
1360
  const remountTui = async () => {
1242
1361
  if (remountPromise) {
@@ -1396,9 +1515,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1396
1515
  activeTurnAbort?.abort();
1397
1516
  activeTurnAbort = null;
1398
1517
  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
1518
  const queued = store.getState().queuedMessage;
1403
1519
  const cancelledEntries = [
1404
1520
  ...takePendingTurnEntries(),
@@ -1521,6 +1637,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1521
1637
  dismissPendingApproval();
1522
1638
  dismissPendingSudoPassword();
1523
1639
  exiting = true;
1640
+ killAllBackgroundJobs();
1524
1641
  store.update((current) => ({
1525
1642
  ...current,
1526
1643
  exiting: true,
@@ -1675,6 +1792,87 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1675
1792
  title: 'Model',
1676
1793
  });
1677
1794
  };
1795
+ const openJobsPicker = () => {
1796
+ syncBackgroundJobsState();
1797
+ store.update((current) => ({
1798
+ ...current,
1799
+ commandCursor: 0,
1800
+ cursor: 0,
1801
+ input: '',
1802
+ jobsPickerExpandedId: null,
1803
+ jobsPickerIndex: 0,
1804
+ jobsPickerOpen: true,
1805
+ status: 'Background jobs',
1806
+ }));
1807
+ };
1808
+ const appendJobOutputEntry = async (jobId) => {
1809
+ const id = jobId.trim();
1810
+ await collectBackgroundJobUiOutputMutations({
1811
+ session,
1812
+ projectIndex,
1813
+ jobId: id,
1814
+ });
1815
+ const job = listBackgroundJobs().find((candidate) => candidate.id === id);
1816
+ const buffered = getJobBufferedOutput(id);
1817
+ if (!job || !buffered) {
1818
+ appendError(`Unknown background job id: ${id}`);
1819
+ return;
1820
+ }
1821
+ const dropped = buffered.droppedChars > 0
1822
+ ? `... (${buffered.droppedChars} chars of older output dropped) ...\n`
1823
+ : '';
1824
+ appendStaticEntry({
1825
+ body: `${dropped}${buffered.output || '(no output captured)'}`,
1826
+ kind: 'system',
1827
+ preformatted: true,
1828
+ title: `${job.id} output — ${truncate(job.command, 80)}`,
1829
+ });
1830
+ };
1831
+ const selectedJobsPickerId = () => {
1832
+ const current = store.getState();
1833
+ if (!current.backgroundJobs.length)
1834
+ return null;
1835
+ const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
1836
+ return current.backgroundJobs[index]?.id ?? null;
1837
+ };
1838
+ const handleJobsPickerOutput = async () => {
1839
+ const jobId = selectedJobsPickerId();
1840
+ if (!jobId)
1841
+ return;
1842
+ await collectBackgroundJobUiOutputMutations({
1843
+ session,
1844
+ projectIndex,
1845
+ jobId,
1846
+ });
1847
+ syncBackgroundJobsState();
1848
+ store.update((current) => ({
1849
+ ...current,
1850
+ jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
1851
+ }));
1852
+ };
1853
+ const handleJobsPickerKill = async () => {
1854
+ const jobId = selectedJobsPickerId();
1855
+ if (!jobId)
1856
+ return;
1857
+ const killed = await killBackgroundJob(jobId);
1858
+ await collectBackgroundJobUiKillMutations({
1859
+ session,
1860
+ projectIndex,
1861
+ jobId,
1862
+ result: killed,
1863
+ });
1864
+ syncBackgroundJobsState();
1865
+ if (!killed.ok) {
1866
+ appendError(killed.error ?? 'Background job kill failed.');
1867
+ }
1868
+ else if (killed.alreadyFinished) {
1869
+ appendStaticEntry({
1870
+ body: `${jobId} had already finished.`,
1871
+ kind: 'system',
1872
+ title: 'Background jobs',
1873
+ });
1874
+ }
1875
+ };
1678
1876
  const handleInlineModelSelection = async () => {
1679
1877
  const current = store.getState();
1680
1878
  const options = buildModelPickerOptions(current.currentModelId, current.serverModels);
@@ -1761,6 +1959,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1761
1959
  try {
1762
1960
  const snapshot = await loadInteractiveSession(selected.id);
1763
1961
  applySessionSnapshot(session, snapshot);
1962
+ setBackgroundJobSession(session.sessionId);
1963
+ setScratchSession(session.sessionId);
1964
+ syncBackgroundJobsState();
1965
+ setTodoSession(session.sessionId);
1966
+ syncTodosState();
1764
1967
  await saveActiveSession();
1765
1968
  syncShellStateFromSession();
1766
1969
  store.update((next) => ({
@@ -1802,8 +2005,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1802
2005
  if (!queued || store.getState().busy || exiting) {
1803
2006
  return;
1804
2007
  }
1805
- // Rehydrate attachments/chunks before submit: handleSubmit expands
1806
- // pastedChunks from the store and the turn picks up imageAttachments.
1807
2008
  store.update((current) => ({
1808
2009
  ...current,
1809
2010
  imageAttachments: queued.imageAttachments,
@@ -1830,9 +2031,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1830
2031
  });
1831
2032
  return;
1832
2033
  }
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
2034
  const pending = store.getState();
1837
2035
  const snapshot = {
1838
2036
  body: String(rawInput ?? ''),
@@ -1887,6 +2085,46 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1887
2085
  });
1888
2086
  return;
1889
2087
  }
2088
+ if (input === '/jobs' || input.startsWith('/jobs ')) {
2089
+ const jobsArgs = input.slice('/jobs'.length).trim();
2090
+ if (!jobsArgs) {
2091
+ openJobsPicker();
2092
+ return;
2093
+ }
2094
+ const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
2095
+ if (killMatch) {
2096
+ const jobId = killMatch[1];
2097
+ const killed = await killBackgroundJob(jobId);
2098
+ await collectBackgroundJobUiKillMutations({
2099
+ session,
2100
+ projectIndex,
2101
+ jobId,
2102
+ result: killed,
2103
+ });
2104
+ if (!killed.ok) {
2105
+ appendError(killed.error ?? 'Background job kill failed.');
2106
+ }
2107
+ else if (killed.alreadyFinished) {
2108
+ appendStaticEntry({
2109
+ body: `${jobId} had already finished.`,
2110
+ kind: 'system',
2111
+ title: 'Background jobs',
2112
+ });
2113
+ }
2114
+ return;
2115
+ }
2116
+ const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
2117
+ if (outputMatch) {
2118
+ await appendJobOutputEntry(outputMatch[1]);
2119
+ return;
2120
+ }
2121
+ appendStaticEntry({
2122
+ body: 'Usage: /jobs — open the jobs picker · /jobs output <id> — full output · /jobs kill <id> — kill one',
2123
+ kind: 'system',
2124
+ title: 'Background jobs',
2125
+ });
2126
+ return;
2127
+ }
1890
2128
  if (input === '/usage') {
1891
2129
  store.update((current) => ({
1892
2130
  ...current,
@@ -1962,6 +2200,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1962
2200
  }
1963
2201
  if (input === '/clear') {
1964
2202
  clearConversation(session);
2203
+ clearTodos();
2204
+ syncTodosState();
1965
2205
  latestUsageSummary = null;
1966
2206
  await saveActiveSession();
1967
2207
  store.replaceTranscript([
@@ -1983,6 +2223,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1983
2223
  const turnAbort = new AbortController();
1984
2224
  activeTurnAbort = turnAbort;
1985
2225
  lastTurnStartedAt = turnStartedAt;
2226
+ todosTouchedThisTurn = false;
1986
2227
  const userEntry = {
1987
2228
  body: input,
1988
2229
  kind: 'user',
@@ -2037,6 +2278,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2037
2278
  await appendSettledTurnEntries(turnEntries, result.text ?? '', () => turnGeneration === activeTurnGeneration && !turnAbort.signal.aborted);
2038
2279
  if (turnGeneration !== activeTurnGeneration)
2039
2280
  return;
2281
+ if (todosTouchedThisTurn) {
2282
+ todosTouchedThisTurn = false;
2283
+ const todoItems = listTodos();
2284
+ if (todoItems.length > 0) {
2285
+ appendStaticEntry({
2286
+ body: '',
2287
+ kind: 'system',
2288
+ title: `To-dos · ${formatTodoProgress(todoItems)}`,
2289
+ todoList: todoItems,
2290
+ });
2291
+ if (todoItems.every((item) => item.status === 'completed')) {
2292
+ clearTodos();
2293
+ syncTodosState();
2294
+ }
2295
+ }
2296
+ }
2040
2297
  store.update((current) => ({
2041
2298
  ...current,
2042
2299
  busy: false,
@@ -2085,8 +2342,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2085
2342
  appendError(error.message);
2086
2343
  }
2087
2344
  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
2345
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2091
2346
  await flushQueuedMessage();
2092
2347
  }
@@ -2119,6 +2374,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2119
2374
  };
2120
2375
  session.onToolEvent = (event) => {
2121
2376
  syncShellStateFromSession();
2377
+ if (event.call.name === 'update_todos') {
2378
+ syncTodosState();
2379
+ if (event.result?.ok === true) {
2380
+ todosTouchedThisTurn = true;
2381
+ return;
2382
+ }
2383
+ }
2122
2384
  if (isFileChangeTool(event.call.name)) {
2123
2385
  const entry = buildFileChangeEntry(event);
2124
2386
  store.appendWorkingTool(entry);
@@ -2127,6 +2389,52 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2127
2389
  }
2128
2390
  store.appendWorkingTool(buildWorkingToolEntry(event));
2129
2391
  };
2392
+ const jobDetailLines = (jobId) => {
2393
+ const buffered = getJobBufferedOutput(jobId);
2394
+ if (!buffered)
2395
+ return [];
2396
+ const lines = buffered.output
2397
+ .replace(/\r\n/g, '\n')
2398
+ .replace(/\r/g, '\n')
2399
+ .split('\n')
2400
+ .filter((line) => line.trim().length > 0);
2401
+ const visible = lines.slice(-20);
2402
+ return buffered.droppedChars > 0
2403
+ ? [
2404
+ `... (${buffered.droppedChars} chars of older output dropped) ...`,
2405
+ ...visible,
2406
+ ]
2407
+ : visible;
2408
+ };
2409
+ const syncBackgroundJobsState = () => {
2410
+ const jobsForDisplay = listBackgroundJobs().map((job) => {
2411
+ const preview = getJobOutputPreview(job.id, 3);
2412
+ return {
2413
+ id: job.id,
2414
+ command: job.command,
2415
+ status: job.status,
2416
+ exitCode: job.exitCode,
2417
+ startedAt: job.startedAt,
2418
+ endedAt: job.endedAt,
2419
+ firstOutputLine: preview?.firstLine ?? '',
2420
+ tailLines: preview?.tailLines ?? [],
2421
+ detailLines: jobDetailLines(job.id),
2422
+ };
2423
+ });
2424
+ store.update((current) => ({
2425
+ ...current,
2426
+ backgroundJobs: jobsForDisplay,
2427
+ jobsPickerExpandedId: jobsForDisplay.some((job) => job.id === current.jobsPickerExpandedId)
2428
+ ? current.jobsPickerExpandedId
2429
+ : null,
2430
+ }));
2431
+ };
2432
+ setBackgroundJobUpdateHook((snapshot) => {
2433
+ syncBackgroundJobsState();
2434
+ if (snapshot.status !== 'running') {
2435
+ appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2436
+ }
2437
+ });
2130
2438
  projectIndex.onStatus = session.onStatus;
2131
2439
  projectIndex.onContextLog = session.onContextLog;
2132
2440
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
@@ -2186,6 +2494,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2186
2494
  onResumeSession: handleInlineResumeSelection,
2187
2495
  onSelectionCopy: handleAppSelectionCopy,
2188
2496
  onSelectModel: handleInlineModelSelection,
2497
+ onJobsPickerOutput: handleJobsPickerOutput,
2498
+ onJobsPickerKill: handleJobsPickerKill,
2189
2499
  onSudoPasswordInput: handleSudoPasswordInput,
2190
2500
  onSubmit: handleSubmit,
2191
2501
  };
@@ -2199,7 +2509,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2199
2509
  }
2200
2510
  }, 120);
2201
2511
  const clockTimer = setInterval(() => {
2202
- if (store.getState().busy) {
2512
+ if (hasRunningBackgroundJobs()) {
2513
+ syncBackgroundJobsState();
2514
+ }
2515
+ if (store.getState().busy || hasRunningBackgroundJobs()) {
2203
2516
  renderCurrentFrame();
2204
2517
  }
2205
2518
  }, 1000);
@@ -2243,6 +2556,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2243
2556
  clearInterval(clockTimer);
2244
2557
  unsubscribe();
2245
2558
  clearLiveFrameRemountTimer();
2559
+ terminalTitle.dispose();
2560
+ killAllBackgroundJobs();
2561
+ setBackgroundJobSession(null);
2562
+ setScratchSession(null);
2563
+ setTodoSession(null);
2564
+ setBackgroundJobUpdateHook(null);
2246
2565
  await bridge.close();
2247
2566
  setCommandOutputHook(null);
2248
2567
  }
@@ -91,14 +91,11 @@ function normalizeChildMessage(raw) {
91
91
  export function resolveTuiBinaryPath() {
92
92
  const binaryName = process.platform === 'win32' ? 'thegitai-tui.exe' : 'thegitai-tui';
93
93
  const platformPackage = `@thegitai/tui-${process.platform}-${process.arch}`;
94
- // 1) Published per-platform optional dependency (the installed-from-npm path).
95
94
  try {
96
95
  return requireFromHere.resolve(`${platformPackage}/${binaryName}`);
97
96
  }
98
97
  catch {
99
- // Not installed (unsupported platform yet, or local dev) — fall through.
100
98
  }
101
- // 2) Local dev build: `npm run build:tui` populates the workspace bin/.
102
99
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
103
100
  const devCandidates = [
104
101
  path.join(moduleDir, '../../../bin', binaryName),
@@ -137,7 +134,6 @@ export function createRatatuiBridge() {
137
134
  }
138
135
  }
139
136
  catch {
140
- // ignore malformed protocol lines
141
137
  }
142
138
  });
143
139
  child.on('exit', () => {