@thegitai/cli 1.0.0-beta.8 → 1.0.0-preview.1

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 (55) hide show
  1. package/README.md +36 -2
  2. package/dist/bin/ai.js +135 -26
  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 +3 -38
  7. package/dist/src/api/chat.js +57 -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 +1 -3
  21. package/dist/src/scanner.js +50 -12
  22. package/dist/src/scratch-dir.js +57 -0
  23. package/dist/src/secret-preview.js +0 -10
  24. package/dist/src/session-safety.js +0 -19
  25. package/dist/src/session-store.js +0 -1
  26. package/dist/src/todo-list.js +106 -0
  27. package/dist/src/tool-executor.js +159 -18
  28. package/dist/src/tools/delete-file.js +1 -1
  29. package/dist/src/tools/index.js +6 -0
  30. package/dist/src/tools/patch-file.js +3 -2
  31. package/dist/src/tools/path-suggest.js +81 -8
  32. package/dist/src/tools/read-document.js +2 -2
  33. package/dist/src/tools/read-file.js +14 -7
  34. package/dist/src/tools/replace-document-text.js +3 -11
  35. package/dist/src/tools/restore-checkpoint.js +1 -1
  36. package/dist/src/tools/run-command.js +83 -16
  37. package/dist/src/tools/run-node-script.js +3 -1
  38. package/dist/src/tools/shell-job-kill.js +48 -0
  39. package/dist/src/tools/shell-job-output.js +51 -0
  40. package/dist/src/tools/str-replace.js +3 -2
  41. package/dist/src/tools/undo-edit.js +1 -1
  42. package/dist/src/tools/update-todos.js +27 -0
  43. package/dist/src/tools/write-file.js +1 -1
  44. package/dist/src/tree-sitter-runtime.js +8 -1
  45. package/dist/src/ui/repl.js +313 -23
  46. package/dist/src/ui/tui/bridge.js +0 -4
  47. package/dist/src/ui/tui/build-frame.js +220 -24
  48. package/dist/src/ui/tui/shell-input.js +33 -4
  49. package/dist/src/ui/tui/terminal-title.js +81 -0
  50. package/dist/src/version.js +0 -6
  51. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  52. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  53. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  54. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  55. package/package.json +14 -15
@@ -1,11 +1,15 @@
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';
7
10
  import { cancelActiveCommand } from '../executor.js';
8
11
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
12
+ import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
9
13
  import { clearConversation, } from '../session.js';
10
14
  import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
11
15
  import { truncate } from '../utils.js';
@@ -102,6 +106,10 @@ export const SLASH_COMMANDS = [
102
106
  command: '/resume',
103
107
  description: 'Open the session picker',
104
108
  },
109
+ {
110
+ command: '/jobs',
111
+ description: 'Background jobs: pick to view output or kill',
112
+ },
105
113
  {
106
114
  command: '/clear',
107
115
  description: 'Clear conversation history',
@@ -643,12 +651,33 @@ function buildWorkingToolEntry(event) {
643
651
  : '';
644
652
  if (call.name === 'run_command') {
645
653
  const command = String(call.args?.command ?? result?.command ?? '').trim();
654
+ if (result?.backgrounded === true && result?.jobId) {
655
+ return {
656
+ body: `$ ${truncate(command, 180)}\nBackground job ${String(result.jobId)} started.${error}`,
657
+ kind: result?.ok === true ? 'tool' : 'error',
658
+ preformatted: true,
659
+ title: 'Shell',
660
+ };
661
+ }
646
662
  return {
647
663
  body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
648
664
  kind: result?.ok === true ? 'tool' : 'error',
649
665
  title: 'Shell',
650
666
  };
651
667
  }
668
+ if (call.name === 'shell_job_output' || call.name === 'shell_job_kill') {
669
+ const jobId = String(result?.jobId ?? call.args?.job_id ?? '').trim();
670
+ const status = String(result?.status ?? '').trim();
671
+ const exitCode = result?.exitCode;
672
+ const stateText = status
673
+ ? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
674
+ : formatToolResultState(result);
675
+ return {
676
+ body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
677
+ kind: result?.ok === true ? 'tool' : 'error',
678
+ title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
679
+ };
680
+ }
652
681
  if (call.name === 'run_node_script') {
653
682
  return {
654
683
  body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
@@ -666,6 +695,32 @@ function buildWorkingToolEntry(event) {
666
695
  title: 'Tool',
667
696
  };
668
697
  }
698
+ function buildBackgroundJobNoticeEntry(snapshot) {
699
+ const command = truncate(snapshot.command, 120);
700
+ const ran = formatJobElapsed((snapshot.endedAt ?? Date.now()) - snapshot.startedAt);
701
+ if (snapshot.status === 'error') {
702
+ return {
703
+ body: `✖ ${snapshot.id} (${command}) failed to start`,
704
+ kind: 'error',
705
+ preformatted: true,
706
+ title: 'Background job',
707
+ };
708
+ }
709
+ if (snapshot.status === 'killed') {
710
+ return {
711
+ body: `■ ${snapshot.id} (${command}) killed · ran ${ran}`,
712
+ kind: 'system',
713
+ preformatted: true,
714
+ title: 'Background job',
715
+ };
716
+ }
717
+ return {
718
+ body: `${snapshot.exitCode === 0 ? '✓' : '✖'} ${snapshot.id} (${command}) exited (code ${snapshot.exitCode ?? 1}) · ran ${ran}`,
719
+ kind: snapshot.exitCode === 0 ? 'system' : 'error',
720
+ preformatted: true,
721
+ title: 'Background job',
722
+ };
723
+ }
669
724
  function findPendingToolCallByName(pendingCalls, name) {
670
725
  for (const [id, call] of pendingCalls) {
671
726
  if (call.name !== name)
@@ -814,11 +869,16 @@ function createShellStore(initialState) {
814
869
  },
815
870
  appendWorkingTool: (entry) => {
816
871
  const lastEntry = state.workingTools.at(-1);
817
- if (sameTranscriptDraft(lastEntry, entry))
872
+ const isDuplicate = sameTranscriptDraft(lastEntry, entry);
873
+ if (isDuplicate && state.commandLog.length === 0) {
818
874
  return;
875
+ }
819
876
  state = {
820
877
  ...state,
821
- workingTools: [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
878
+ commandLog: [],
879
+ workingTools: isDuplicate
880
+ ? state.workingTools
881
+ : [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
822
882
  };
823
883
  notify();
824
884
  },
@@ -868,6 +928,7 @@ function createInitialShellState(session, serverModels, debugUi) {
868
928
  approvalCursor: getDefaultApprovalCursor(),
869
929
  approvalPrompt: null,
870
930
  autoYes: session.autoYes,
931
+ backgroundJobs: [],
871
932
  busy: false,
872
933
  busySince: null,
873
934
  clockNow: Date.now(),
@@ -879,6 +940,9 @@ function createInitialShellState(session, serverModels, debugUi) {
879
940
  exiting: false,
880
941
  input: '',
881
942
  maxToolSteps: session.maxToolSteps,
943
+ jobsPickerExpandedId: null,
944
+ jobsPickerIndex: 0,
945
+ jobsPickerOpen: false,
882
946
  modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
883
947
  modelPickerOpen: false,
884
948
  projectRoot: session.rootDir,
@@ -899,6 +963,7 @@ function createInitialShellState(session, serverModels, debugUi) {
899
963
  exitConfirmUntil: null,
900
964
  thinkingTitle: '',
901
965
  thinkingNotes: [],
966
+ todos: listTodos(),
902
967
  tokenUsage: formatClientTokenUsage(null),
903
968
  transcript: [],
904
969
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
@@ -951,6 +1016,14 @@ function thinkingNoteFromStatus(status) {
951
1016
  const note = text.slice('Thinking:'.length).trim();
952
1017
  return note ? note : null;
953
1018
  }
1019
+ function stripMarkdownEmphasis(text) {
1020
+ return text
1021
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
1022
+ .replace(/___(.+?)___/g, '$1')
1023
+ .replace(/\*\*(.+?)\*\*/g, '$1')
1024
+ .replace(/__(.+?)__/g, '$1')
1025
+ .replace(/(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)/g, '$1');
1026
+ }
954
1027
  function splitThinkingLines(text) {
955
1028
  return text
956
1029
  .split('\n')
@@ -959,13 +1032,13 @@ function splitThinkingLines(text) {
959
1032
  .flatMap((line) => line
960
1033
  .split(/(?<=[.!?])\s+(?=[A-Z0-9"'`])/)
961
1034
  .map((part) => part.trim())
962
- .filter(Boolean))
963
- .map((line) => truncate(line, 120));
1035
+ .filter(Boolean));
964
1036
  }
965
1037
  function thinkingPanelFromStatus(status) {
966
- const text = thinkingNoteFromStatus(status);
967
- if (!text)
1038
+ const rawText = thinkingNoteFromStatus(status);
1039
+ if (!rawText)
968
1040
  return null;
1041
+ const text = stripMarkdownEmphasis(rawText);
969
1042
  const rawLines = text
970
1043
  .split('\n')
971
1044
  .map((line) => line.trim())
@@ -974,12 +1047,12 @@ function thinkingPanelFromStatus(status) {
974
1047
  return null;
975
1048
  if (rawLines.length === 1 && rawLines[0].length <= 72) {
976
1049
  return {
977
- title: truncate(rawLines[0], 72),
1050
+ title: rawLines[0],
978
1051
  notes: [],
979
1052
  };
980
1053
  }
981
1054
  const title = rawLines.length > 1 && rawLines[0].length <= 72
982
- ? truncate(rawLines[0], 72)
1055
+ ? rawLines[0]
983
1056
  : 'Thinking';
984
1057
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
985
1058
  return {
@@ -997,7 +1070,10 @@ export function getInputCommandToken(input) {
997
1070
  if (!trimmed.startsWith('/'))
998
1071
  return '';
999
1072
  const firstSpaceIndex = trimmed.indexOf(' ');
1000
- return firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1073
+ const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1074
+ if (token.indexOf('/', 1) !== -1)
1075
+ return '';
1076
+ return token;
1001
1077
  }
1002
1078
  function shouldShowCommandPalette(state) {
1003
1079
  const trimmed = String(state.input ?? '').trim();
@@ -1006,7 +1082,8 @@ function shouldShowCommandPalette(state) {
1006
1082
  !state.modelPickerOpen &&
1007
1083
  !state.resumePickerOpen &&
1008
1084
  trimmed.startsWith('/') &&
1009
- !trimmed.includes(' '));
1085
+ !trimmed.includes(' ') &&
1086
+ getInputCommandToken(trimmed) !== '');
1010
1087
  }
1011
1088
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
1012
1089
  const currentShowsCommands = shouldShowCommandPalette(current);
@@ -1179,6 +1256,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1179
1256
  throw new Error('Client TUI requires an interactive terminal.');
1180
1257
  }
1181
1258
  await withTuiMode(async () => {
1259
+ setBackgroundJobSession(session.sessionId);
1260
+ setTodoSession(session.sessionId);
1182
1261
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1183
1262
  store.replaceTranscript(createSessionTranscript(session));
1184
1263
  let currentServerModels = serverModels;
@@ -1201,6 +1280,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1201
1280
  let latestUsageSummary = null;
1202
1281
  let pendingTurnEntries = [];
1203
1282
  let activeTurnAbort = null;
1283
+ let todosTouchedThisTurn = false;
1284
+ const syncTodosState = () => {
1285
+ store.update((current) => ({ ...current, todos: listTodos() }));
1286
+ };
1204
1287
  let activeTurnGeneration = 0;
1205
1288
  let exitCtrlCArmed = false;
1206
1289
  let exitCtrlCTimer = null;
@@ -1229,14 +1312,23 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1229
1312
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1230
1313
  }, 2500);
1231
1314
  };
1315
+ const terminalTitle = createTerminalTitleController();
1316
+ const syncTerminalTitle = () => {
1317
+ const state = store.getState();
1318
+ terminalTitle.sync({
1319
+ awaitingReview: Boolean(state.approvalPrompt || state.sudoPrompt),
1320
+ busy: state.busy,
1321
+ });
1322
+ };
1232
1323
  const renderCurrentFrame = () => {
1233
1324
  if (!tuiReady)
1234
1325
  return;
1235
1326
  const state = store.getState();
1327
+ syncTerminalTitle();
1236
1328
  const elapsedSeconds = state.busySince
1237
1329
  ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1238
1330
  : 0;
1239
- bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds));
1331
+ bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1240
1332
  };
1241
1333
  const remountTui = async () => {
1242
1334
  if (remountPromise) {
@@ -1396,9 +1488,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1396
1488
  activeTurnAbort?.abort();
1397
1489
  activeTurnAbort = null;
1398
1490
  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
1491
  const queued = store.getState().queuedMessage;
1403
1492
  const cancelledEntries = [
1404
1493
  ...takePendingTurnEntries(),
@@ -1521,6 +1610,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1521
1610
  dismissPendingApproval();
1522
1611
  dismissPendingSudoPassword();
1523
1612
  exiting = true;
1613
+ killAllBackgroundJobs();
1524
1614
  store.update((current) => ({
1525
1615
  ...current,
1526
1616
  exiting: true,
@@ -1675,6 +1765,87 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1675
1765
  title: 'Model',
1676
1766
  });
1677
1767
  };
1768
+ const openJobsPicker = () => {
1769
+ syncBackgroundJobsState();
1770
+ store.update((current) => ({
1771
+ ...current,
1772
+ commandCursor: 0,
1773
+ cursor: 0,
1774
+ input: '',
1775
+ jobsPickerExpandedId: null,
1776
+ jobsPickerIndex: 0,
1777
+ jobsPickerOpen: true,
1778
+ status: 'Background jobs',
1779
+ }));
1780
+ };
1781
+ const appendJobOutputEntry = async (jobId) => {
1782
+ const id = jobId.trim();
1783
+ await collectBackgroundJobUiOutputMutations({
1784
+ session,
1785
+ projectIndex,
1786
+ jobId: id,
1787
+ });
1788
+ const job = listBackgroundJobs().find((candidate) => candidate.id === id);
1789
+ const buffered = getJobBufferedOutput(id);
1790
+ if (!job || !buffered) {
1791
+ appendError(`Unknown background job id: ${id}`);
1792
+ return;
1793
+ }
1794
+ const dropped = buffered.droppedChars > 0
1795
+ ? `... (${buffered.droppedChars} chars of older output dropped) ...\n`
1796
+ : '';
1797
+ appendStaticEntry({
1798
+ body: `${dropped}${buffered.output || '(no output captured)'}`,
1799
+ kind: 'system',
1800
+ preformatted: true,
1801
+ title: `${job.id} output — ${truncate(job.command, 80)}`,
1802
+ });
1803
+ };
1804
+ const selectedJobsPickerId = () => {
1805
+ const current = store.getState();
1806
+ if (!current.backgroundJobs.length)
1807
+ return null;
1808
+ const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
1809
+ return current.backgroundJobs[index]?.id ?? null;
1810
+ };
1811
+ const handleJobsPickerOutput = async () => {
1812
+ const jobId = selectedJobsPickerId();
1813
+ if (!jobId)
1814
+ return;
1815
+ await collectBackgroundJobUiOutputMutations({
1816
+ session,
1817
+ projectIndex,
1818
+ jobId,
1819
+ });
1820
+ syncBackgroundJobsState();
1821
+ store.update((current) => ({
1822
+ ...current,
1823
+ jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
1824
+ }));
1825
+ };
1826
+ const handleJobsPickerKill = async () => {
1827
+ const jobId = selectedJobsPickerId();
1828
+ if (!jobId)
1829
+ return;
1830
+ const killed = await killBackgroundJob(jobId);
1831
+ await collectBackgroundJobUiKillMutations({
1832
+ session,
1833
+ projectIndex,
1834
+ jobId,
1835
+ result: killed,
1836
+ });
1837
+ syncBackgroundJobsState();
1838
+ if (!killed.ok) {
1839
+ appendError(killed.error ?? 'Background job kill failed.');
1840
+ }
1841
+ else if (killed.alreadyFinished) {
1842
+ appendStaticEntry({
1843
+ body: `${jobId} had already finished.`,
1844
+ kind: 'system',
1845
+ title: 'Background jobs',
1846
+ });
1847
+ }
1848
+ };
1678
1849
  const handleInlineModelSelection = async () => {
1679
1850
  const current = store.getState();
1680
1851
  const options = buildModelPickerOptions(current.currentModelId, current.serverModels);
@@ -1761,6 +1932,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1761
1932
  try {
1762
1933
  const snapshot = await loadInteractiveSession(selected.id);
1763
1934
  applySessionSnapshot(session, snapshot);
1935
+ setBackgroundJobSession(session.sessionId);
1936
+ syncBackgroundJobsState();
1937
+ setTodoSession(session.sessionId);
1938
+ syncTodosState();
1764
1939
  await saveActiveSession();
1765
1940
  syncShellStateFromSession();
1766
1941
  store.update((next) => ({
@@ -1802,8 +1977,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1802
1977
  if (!queued || store.getState().busy || exiting) {
1803
1978
  return;
1804
1979
  }
1805
- // Rehydrate attachments/chunks before submit: handleSubmit expands
1806
- // pastedChunks from the store and the turn picks up imageAttachments.
1807
1980
  store.update((current) => ({
1808
1981
  ...current,
1809
1982
  imageAttachments: queued.imageAttachments,
@@ -1830,9 +2003,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1830
2003
  });
1831
2004
  return;
1832
2005
  }
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
2006
  const pending = store.getState();
1837
2007
  const snapshot = {
1838
2008
  body: String(rawInput ?? ''),
@@ -1887,6 +2057,46 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1887
2057
  });
1888
2058
  return;
1889
2059
  }
2060
+ if (input === '/jobs' || input.startsWith('/jobs ')) {
2061
+ const jobsArgs = input.slice('/jobs'.length).trim();
2062
+ if (!jobsArgs) {
2063
+ openJobsPicker();
2064
+ return;
2065
+ }
2066
+ const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
2067
+ if (killMatch) {
2068
+ const jobId = killMatch[1];
2069
+ const killed = await killBackgroundJob(jobId);
2070
+ await collectBackgroundJobUiKillMutations({
2071
+ session,
2072
+ projectIndex,
2073
+ jobId,
2074
+ result: killed,
2075
+ });
2076
+ if (!killed.ok) {
2077
+ appendError(killed.error ?? 'Background job kill failed.');
2078
+ }
2079
+ else if (killed.alreadyFinished) {
2080
+ appendStaticEntry({
2081
+ body: `${jobId} had already finished.`,
2082
+ kind: 'system',
2083
+ title: 'Background jobs',
2084
+ });
2085
+ }
2086
+ return;
2087
+ }
2088
+ const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
2089
+ if (outputMatch) {
2090
+ await appendJobOutputEntry(outputMatch[1]);
2091
+ return;
2092
+ }
2093
+ appendStaticEntry({
2094
+ body: 'Usage: /jobs — open the jobs picker · /jobs output <id> — full output · /jobs kill <id> — kill one',
2095
+ kind: 'system',
2096
+ title: 'Background jobs',
2097
+ });
2098
+ return;
2099
+ }
1890
2100
  if (input === '/usage') {
1891
2101
  store.update((current) => ({
1892
2102
  ...current,
@@ -1962,6 +2172,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1962
2172
  }
1963
2173
  if (input === '/clear') {
1964
2174
  clearConversation(session);
2175
+ clearTodos();
2176
+ syncTodosState();
1965
2177
  latestUsageSummary = null;
1966
2178
  await saveActiveSession();
1967
2179
  store.replaceTranscript([
@@ -1983,6 +2195,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1983
2195
  const turnAbort = new AbortController();
1984
2196
  activeTurnAbort = turnAbort;
1985
2197
  lastTurnStartedAt = turnStartedAt;
2198
+ todosTouchedThisTurn = false;
1986
2199
  const userEntry = {
1987
2200
  body: input,
1988
2201
  kind: 'user',
@@ -2037,6 +2250,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2037
2250
  await appendSettledTurnEntries(turnEntries, result.text ?? '', () => turnGeneration === activeTurnGeneration && !turnAbort.signal.aborted);
2038
2251
  if (turnGeneration !== activeTurnGeneration)
2039
2252
  return;
2253
+ if (todosTouchedThisTurn) {
2254
+ todosTouchedThisTurn = false;
2255
+ const todoItems = listTodos();
2256
+ if (todoItems.length > 0) {
2257
+ appendStaticEntry({
2258
+ body: '',
2259
+ kind: 'system',
2260
+ title: `To-dos · ${formatTodoProgress(todoItems)}`,
2261
+ todoList: todoItems,
2262
+ });
2263
+ if (todoItems.every((item) => item.status === 'completed')) {
2264
+ clearTodos();
2265
+ syncTodosState();
2266
+ }
2267
+ }
2268
+ }
2040
2269
  store.update((current) => ({
2041
2270
  ...current,
2042
2271
  busy: false,
@@ -2085,8 +2314,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2085
2314
  appendError(error.message);
2086
2315
  }
2087
2316
  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
2317
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2091
2318
  await flushQueuedMessage();
2092
2319
  }
@@ -2119,6 +2346,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2119
2346
  };
2120
2347
  session.onToolEvent = (event) => {
2121
2348
  syncShellStateFromSession();
2349
+ if (event.call.name === 'update_todos') {
2350
+ syncTodosState();
2351
+ if (event.result?.ok === true) {
2352
+ todosTouchedThisTurn = true;
2353
+ return;
2354
+ }
2355
+ }
2122
2356
  if (isFileChangeTool(event.call.name)) {
2123
2357
  const entry = buildFileChangeEntry(event);
2124
2358
  store.appendWorkingTool(entry);
@@ -2127,6 +2361,52 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2127
2361
  }
2128
2362
  store.appendWorkingTool(buildWorkingToolEntry(event));
2129
2363
  };
2364
+ const jobDetailLines = (jobId) => {
2365
+ const buffered = getJobBufferedOutput(jobId);
2366
+ if (!buffered)
2367
+ return [];
2368
+ const lines = buffered.output
2369
+ .replace(/\r\n/g, '\n')
2370
+ .replace(/\r/g, '\n')
2371
+ .split('\n')
2372
+ .filter((line) => line.trim().length > 0);
2373
+ const visible = lines.slice(-20);
2374
+ return buffered.droppedChars > 0
2375
+ ? [
2376
+ `... (${buffered.droppedChars} chars of older output dropped) ...`,
2377
+ ...visible,
2378
+ ]
2379
+ : visible;
2380
+ };
2381
+ const syncBackgroundJobsState = () => {
2382
+ const jobsForDisplay = listBackgroundJobs().map((job) => {
2383
+ const preview = getJobOutputPreview(job.id, 3);
2384
+ return {
2385
+ id: job.id,
2386
+ command: job.command,
2387
+ status: job.status,
2388
+ exitCode: job.exitCode,
2389
+ startedAt: job.startedAt,
2390
+ endedAt: job.endedAt,
2391
+ firstOutputLine: preview?.firstLine ?? '',
2392
+ tailLines: preview?.tailLines ?? [],
2393
+ detailLines: jobDetailLines(job.id),
2394
+ };
2395
+ });
2396
+ store.update((current) => ({
2397
+ ...current,
2398
+ backgroundJobs: jobsForDisplay,
2399
+ jobsPickerExpandedId: jobsForDisplay.some((job) => job.id === current.jobsPickerExpandedId)
2400
+ ? current.jobsPickerExpandedId
2401
+ : null,
2402
+ }));
2403
+ };
2404
+ setBackgroundJobUpdateHook((snapshot) => {
2405
+ syncBackgroundJobsState();
2406
+ if (snapshot.status !== 'running') {
2407
+ appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2408
+ }
2409
+ });
2130
2410
  projectIndex.onStatus = session.onStatus;
2131
2411
  projectIndex.onContextLog = session.onContextLog;
2132
2412
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
@@ -2186,6 +2466,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2186
2466
  onResumeSession: handleInlineResumeSelection,
2187
2467
  onSelectionCopy: handleAppSelectionCopy,
2188
2468
  onSelectModel: handleInlineModelSelection,
2469
+ onJobsPickerOutput: handleJobsPickerOutput,
2470
+ onJobsPickerKill: handleJobsPickerKill,
2189
2471
  onSudoPasswordInput: handleSudoPasswordInput,
2190
2472
  onSubmit: handleSubmit,
2191
2473
  };
@@ -2199,7 +2481,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2199
2481
  }
2200
2482
  }, 120);
2201
2483
  const clockTimer = setInterval(() => {
2202
- if (store.getState().busy) {
2484
+ if (hasRunningBackgroundJobs()) {
2485
+ syncBackgroundJobsState();
2486
+ }
2487
+ if (store.getState().busy || hasRunningBackgroundJobs()) {
2203
2488
  renderCurrentFrame();
2204
2489
  }
2205
2490
  }, 1000);
@@ -2243,6 +2528,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2243
2528
  clearInterval(clockTimer);
2244
2529
  unsubscribe();
2245
2530
  clearLiveFrameRemountTimer();
2531
+ terminalTitle.dispose();
2532
+ killAllBackgroundJobs();
2533
+ setBackgroundJobSession(null);
2534
+ setTodoSession(null);
2535
+ setBackgroundJobUpdateHook(null);
2246
2536
  await bridge.close();
2247
2537
  setCommandOutputHook(null);
2248
2538
  }
@@ -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', () => {