@thegitai/cli 1.0.0-beta.2 → 1.0.0-beta.21

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 +37 -2
  2. package/dist/bin/ai.js +148 -75
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +6 -4
  6. package/dist/src/api/browser-login.js +7 -41
  7. package/dist/src/api/chat.js +77 -20
  8. package/dist/src/api/http.js +81 -4
  9. package/dist/src/api/models.js +26 -18
  10. package/dist/src/artifact-policy.js +12 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +60 -0
  13. package/dist/src/client-environment.js +129 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +75 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/edit-journal.js +39 -6
  18. package/dist/src/executor.js +48 -12
  19. package/dist/src/help-text.js +24 -5
  20. package/dist/src/markdown-renderer.js +1 -1
  21. package/dist/src/patcher.js +17 -2
  22. package/dist/src/scanner.js +58 -17
  23. package/dist/src/scratch-dir.js +57 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +64 -31
  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 +164 -18
  29. package/dist/src/tools/delete-file.js +1 -1
  30. package/dist/src/tools/index.js +8 -0
  31. package/dist/src/tools/patch-file.js +16 -2
  32. package/dist/src/tools/path-suggest.js +139 -0
  33. package/dist/src/tools/read-document.js +15 -4
  34. package/dist/src/tools/read-file.js +23 -7
  35. package/dist/src/tools/replace-document-text.js +234 -0
  36. package/dist/src/tools/restore-checkpoint.js +1 -1
  37. package/dist/src/tools/run-command.js +83 -16
  38. package/dist/src/tools/run-node-script.js +3 -1
  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 +16 -2
  42. package/dist/src/tools/undo-edit.js +7 -5
  43. package/dist/src/tools/update-todos.js +27 -0
  44. package/dist/src/tools/write-file.js +14 -1
  45. package/dist/src/tree-sitter-runtime.js +8 -1
  46. package/dist/src/ui/repl.js +315 -24
  47. package/dist/src/ui/tui/bridge.js +2 -6
  48. package/dist/src/ui/tui/build-frame.js +225 -25
  49. package/dist/src/ui/tui/shell-input.js +42 -5
  50. package/dist/src/version.js +29 -0
  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,17 +1,20 @@
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
3
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
4
4
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
5
5
  import { chat, models } from '../api/index.js';
6
6
  import { isTurnCancelledError } from '../api/chat.js';
7
+ import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
8
+ import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
7
9
  import { cancelActiveCommand } from '../executor.js';
8
10
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
11
+ import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
9
12
  import { clearConversation, } from '../session.js';
10
13
  import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
11
14
  import { truncate } from '../utils.js';
12
15
  import { writeClipboardText } from '../core/clipboard.js';
13
16
  import { openUrl } from '../core/open-url.js';
14
- import { formatInteractiveHelpText } from '../help-text.js';
17
+ import { formatAboutCard, formatInteractiveHelpText } from '../help-text.js';
15
18
  import { expandPastedChunks, } from './paste-collapse.js';
16
19
  import { loadPromptHistory, MAX_PROMPT_HISTORY_ENTRIES, } from './prompt-history-store.js';
17
20
  const RESPONSE_STREAM_CHUNK_SIZE = 4;
@@ -86,6 +89,10 @@ export const SLASH_COMMANDS = [
86
89
  command: '/help',
87
90
  description: 'Show available chat commands',
88
91
  },
92
+ {
93
+ command: '/about',
94
+ description: 'Show version and platform info',
95
+ },
89
96
  {
90
97
  command: '/usage',
91
98
  description: 'Show account usage percentage and reset times',
@@ -98,6 +105,10 @@ export const SLASH_COMMANDS = [
98
105
  command: '/resume',
99
106
  description: 'Open the session picker',
100
107
  },
108
+ {
109
+ command: '/jobs',
110
+ description: 'Background jobs: pick to view output or kill',
111
+ },
101
112
  {
102
113
  command: '/clear',
103
114
  description: 'Clear conversation history',
@@ -639,12 +650,33 @@ function buildWorkingToolEntry(event) {
639
650
  : '';
640
651
  if (call.name === 'run_command') {
641
652
  const command = String(call.args?.command ?? result?.command ?? '').trim();
653
+ if (result?.backgrounded === true && result?.jobId) {
654
+ return {
655
+ body: `$ ${truncate(command, 180)}\nBackground job ${String(result.jobId)} started.${error}`,
656
+ kind: result?.ok === true ? 'tool' : 'error',
657
+ preformatted: true,
658
+ title: 'Shell',
659
+ };
660
+ }
642
661
  return {
643
662
  body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
644
663
  kind: result?.ok === true ? 'tool' : 'error',
645
664
  title: 'Shell',
646
665
  };
647
666
  }
667
+ if (call.name === 'shell_job_output' || call.name === 'shell_job_kill') {
668
+ const jobId = String(result?.jobId ?? call.args?.job_id ?? '').trim();
669
+ const status = String(result?.status ?? '').trim();
670
+ const exitCode = result?.exitCode;
671
+ const stateText = status
672
+ ? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
673
+ : formatToolResultState(result);
674
+ return {
675
+ body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
676
+ kind: result?.ok === true ? 'tool' : 'error',
677
+ title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
678
+ };
679
+ }
648
680
  if (call.name === 'run_node_script') {
649
681
  return {
650
682
  body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
@@ -662,6 +694,32 @@ function buildWorkingToolEntry(event) {
662
694
  title: 'Tool',
663
695
  };
664
696
  }
697
+ function buildBackgroundJobNoticeEntry(snapshot) {
698
+ const command = truncate(snapshot.command, 120);
699
+ const ran = formatJobElapsed((snapshot.endedAt ?? Date.now()) - snapshot.startedAt);
700
+ if (snapshot.status === 'error') {
701
+ return {
702
+ body: `✖ ${snapshot.id} (${command}) failed to start`,
703
+ kind: 'error',
704
+ preformatted: true,
705
+ title: 'Background job',
706
+ };
707
+ }
708
+ if (snapshot.status === 'killed') {
709
+ return {
710
+ body: `■ ${snapshot.id} (${command}) killed · ran ${ran}`,
711
+ kind: 'system',
712
+ preformatted: true,
713
+ title: 'Background job',
714
+ };
715
+ }
716
+ return {
717
+ body: `${snapshot.exitCode === 0 ? '✓' : '✖'} ${snapshot.id} (${command}) exited (code ${snapshot.exitCode ?? 1}) · ran ${ran}`,
718
+ kind: snapshot.exitCode === 0 ? 'system' : 'error',
719
+ preformatted: true,
720
+ title: 'Background job',
721
+ };
722
+ }
665
723
  function findPendingToolCallByName(pendingCalls, name) {
666
724
  for (const [id, call] of pendingCalls) {
667
725
  if (call.name !== name)
@@ -810,11 +868,16 @@ function createShellStore(initialState) {
810
868
  },
811
869
  appendWorkingTool: (entry) => {
812
870
  const lastEntry = state.workingTools.at(-1);
813
- if (sameTranscriptDraft(lastEntry, entry))
871
+ const isDuplicate = sameTranscriptDraft(lastEntry, entry);
872
+ if (isDuplicate && state.commandLog.length === 0) {
814
873
  return;
874
+ }
815
875
  state = {
816
876
  ...state,
817
- workingTools: [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
877
+ commandLog: [],
878
+ workingTools: isDuplicate
879
+ ? state.workingTools
880
+ : [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
818
881
  };
819
882
  notify();
820
883
  },
@@ -864,6 +927,7 @@ function createInitialShellState(session, serverModels, debugUi) {
864
927
  approvalCursor: getDefaultApprovalCursor(),
865
928
  approvalPrompt: null,
866
929
  autoYes: session.autoYes,
930
+ backgroundJobs: [],
867
931
  busy: false,
868
932
  busySince: null,
869
933
  clockNow: Date.now(),
@@ -875,6 +939,9 @@ function createInitialShellState(session, serverModels, debugUi) {
875
939
  exiting: false,
876
940
  input: '',
877
941
  maxToolSteps: session.maxToolSteps,
942
+ jobsPickerExpandedId: null,
943
+ jobsPickerIndex: 0,
944
+ jobsPickerOpen: false,
878
945
  modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
879
946
  modelPickerOpen: false,
880
947
  projectRoot: session.rootDir,
@@ -895,6 +962,7 @@ function createInitialShellState(session, serverModels, debugUi) {
895
962
  exitConfirmUntil: null,
896
963
  thinkingTitle: '',
897
964
  thinkingNotes: [],
965
+ todos: listTodos(),
898
966
  tokenUsage: formatClientTokenUsage(null),
899
967
  transcript: [],
900
968
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
@@ -947,6 +1015,14 @@ function thinkingNoteFromStatus(status) {
947
1015
  const note = text.slice('Thinking:'.length).trim();
948
1016
  return note ? note : null;
949
1017
  }
1018
+ function stripMarkdownEmphasis(text) {
1019
+ return text
1020
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
1021
+ .replace(/___(.+?)___/g, '$1')
1022
+ .replace(/\*\*(.+?)\*\*/g, '$1')
1023
+ .replace(/__(.+?)__/g, '$1')
1024
+ .replace(/(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)/g, '$1');
1025
+ }
950
1026
  function splitThinkingLines(text) {
951
1027
  return text
952
1028
  .split('\n')
@@ -955,13 +1031,13 @@ function splitThinkingLines(text) {
955
1031
  .flatMap((line) => line
956
1032
  .split(/(?<=[.!?])\s+(?=[A-Z0-9"'`])/)
957
1033
  .map((part) => part.trim())
958
- .filter(Boolean))
959
- .map((line) => truncate(line, 120));
1034
+ .filter(Boolean));
960
1035
  }
961
1036
  function thinkingPanelFromStatus(status) {
962
- const text = thinkingNoteFromStatus(status);
963
- if (!text)
1037
+ const rawText = thinkingNoteFromStatus(status);
1038
+ if (!rawText)
964
1039
  return null;
1040
+ const text = stripMarkdownEmphasis(rawText);
965
1041
  const rawLines = text
966
1042
  .split('\n')
967
1043
  .map((line) => line.trim())
@@ -970,12 +1046,12 @@ function thinkingPanelFromStatus(status) {
970
1046
  return null;
971
1047
  if (rawLines.length === 1 && rawLines[0].length <= 72) {
972
1048
  return {
973
- title: truncate(rawLines[0], 72),
1049
+ title: rawLines[0],
974
1050
  notes: [],
975
1051
  };
976
1052
  }
977
1053
  const title = rawLines.length > 1 && rawLines[0].length <= 72
978
- ? truncate(rawLines[0], 72)
1054
+ ? rawLines[0]
979
1055
  : 'Thinking';
980
1056
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
981
1057
  return {
@@ -993,7 +1069,10 @@ export function getInputCommandToken(input) {
993
1069
  if (!trimmed.startsWith('/'))
994
1070
  return '';
995
1071
  const firstSpaceIndex = trimmed.indexOf(' ');
996
- return firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1072
+ const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1073
+ if (token.indexOf('/', 1) !== -1)
1074
+ return '';
1075
+ return token;
997
1076
  }
998
1077
  function shouldShowCommandPalette(state) {
999
1078
  const trimmed = String(state.input ?? '').trim();
@@ -1002,7 +1081,8 @@ function shouldShowCommandPalette(state) {
1002
1081
  !state.modelPickerOpen &&
1003
1082
  !state.resumePickerOpen &&
1004
1083
  trimmed.startsWith('/') &&
1005
- !trimmed.includes(' '));
1084
+ !trimmed.includes(' ') &&
1085
+ getInputCommandToken(trimmed) !== '');
1006
1086
  }
1007
1087
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
1008
1088
  const currentShowsCommands = shouldShowCommandPalette(current);
@@ -1175,6 +1255,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1175
1255
  throw new Error('Client TUI requires an interactive terminal.');
1176
1256
  }
1177
1257
  await withTuiMode(async () => {
1258
+ setBackgroundJobSession(session.sessionId);
1259
+ setTodoSession(session.sessionId);
1178
1260
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1179
1261
  store.replaceTranscript(createSessionTranscript(session));
1180
1262
  let currentServerModels = serverModels;
@@ -1197,6 +1279,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1197
1279
  let latestUsageSummary = null;
1198
1280
  let pendingTurnEntries = [];
1199
1281
  let activeTurnAbort = null;
1282
+ let todosTouchedThisTurn = false;
1283
+ const syncTodosState = () => {
1284
+ store.update((current) => ({ ...current, todos: listTodos() }));
1285
+ };
1200
1286
  let activeTurnGeneration = 0;
1201
1287
  let exitCtrlCArmed = false;
1202
1288
  let exitCtrlCTimer = null;
@@ -1232,7 +1318,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1232
1318
  const elapsedSeconds = state.busySince
1233
1319
  ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1234
1320
  : 0;
1235
- bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds));
1321
+ bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1236
1322
  };
1237
1323
  const remountTui = async () => {
1238
1324
  if (remountPromise) {
@@ -1392,9 +1478,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1392
1478
  activeTurnAbort?.abort();
1393
1479
  activeTurnAbort = null;
1394
1480
  cancelActiveCommand();
1395
- // Ctrl+C with a queued message: recall it into the composer for editing
1396
- // rather than auto-submitting it against the cancelled turn. (Esc with a
1397
- // queued message clears the slot in shell-input without cancelling.)
1398
1481
  const queued = store.getState().queuedMessage;
1399
1482
  const cancelledEntries = [
1400
1483
  ...takePendingTurnEntries(),
@@ -1517,6 +1600,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1517
1600
  dismissPendingApproval();
1518
1601
  dismissPendingSudoPassword();
1519
1602
  exiting = true;
1603
+ killAllBackgroundJobs();
1520
1604
  store.update((current) => ({
1521
1605
  ...current,
1522
1606
  exiting: true,
@@ -1671,6 +1755,87 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1671
1755
  title: 'Model',
1672
1756
  });
1673
1757
  };
1758
+ const openJobsPicker = () => {
1759
+ syncBackgroundJobsState();
1760
+ store.update((current) => ({
1761
+ ...current,
1762
+ commandCursor: 0,
1763
+ cursor: 0,
1764
+ input: '',
1765
+ jobsPickerExpandedId: null,
1766
+ jobsPickerIndex: 0,
1767
+ jobsPickerOpen: true,
1768
+ status: 'Background jobs',
1769
+ }));
1770
+ };
1771
+ const appendJobOutputEntry = async (jobId) => {
1772
+ const id = jobId.trim();
1773
+ await collectBackgroundJobUiOutputMutations({
1774
+ session,
1775
+ projectIndex,
1776
+ jobId: id,
1777
+ });
1778
+ const job = listBackgroundJobs().find((candidate) => candidate.id === id);
1779
+ const buffered = getJobBufferedOutput(id);
1780
+ if (!job || !buffered) {
1781
+ appendError(`Unknown background job id: ${id}`);
1782
+ return;
1783
+ }
1784
+ const dropped = buffered.droppedChars > 0
1785
+ ? `... (${buffered.droppedChars} chars of older output dropped) ...\n`
1786
+ : '';
1787
+ appendStaticEntry({
1788
+ body: `${dropped}${buffered.output || '(no output captured)'}`,
1789
+ kind: 'system',
1790
+ preformatted: true,
1791
+ title: `${job.id} output — ${truncate(job.command, 80)}`,
1792
+ });
1793
+ };
1794
+ const selectedJobsPickerId = () => {
1795
+ const current = store.getState();
1796
+ if (!current.backgroundJobs.length)
1797
+ return null;
1798
+ const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
1799
+ return current.backgroundJobs[index]?.id ?? null;
1800
+ };
1801
+ const handleJobsPickerOutput = async () => {
1802
+ const jobId = selectedJobsPickerId();
1803
+ if (!jobId)
1804
+ return;
1805
+ await collectBackgroundJobUiOutputMutations({
1806
+ session,
1807
+ projectIndex,
1808
+ jobId,
1809
+ });
1810
+ syncBackgroundJobsState();
1811
+ store.update((current) => ({
1812
+ ...current,
1813
+ jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
1814
+ }));
1815
+ };
1816
+ const handleJobsPickerKill = async () => {
1817
+ const jobId = selectedJobsPickerId();
1818
+ if (!jobId)
1819
+ return;
1820
+ const killed = await killBackgroundJob(jobId);
1821
+ await collectBackgroundJobUiKillMutations({
1822
+ session,
1823
+ projectIndex,
1824
+ jobId,
1825
+ result: killed,
1826
+ });
1827
+ syncBackgroundJobsState();
1828
+ if (!killed.ok) {
1829
+ appendError(killed.error ?? 'Background job kill failed.');
1830
+ }
1831
+ else if (killed.alreadyFinished) {
1832
+ appendStaticEntry({
1833
+ body: `${jobId} had already finished.`,
1834
+ kind: 'system',
1835
+ title: 'Background jobs',
1836
+ });
1837
+ }
1838
+ };
1674
1839
  const handleInlineModelSelection = async () => {
1675
1840
  const current = store.getState();
1676
1841
  const options = buildModelPickerOptions(current.currentModelId, current.serverModels);
@@ -1757,6 +1922,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1757
1922
  try {
1758
1923
  const snapshot = await loadInteractiveSession(selected.id);
1759
1924
  applySessionSnapshot(session, snapshot);
1925
+ setBackgroundJobSession(session.sessionId);
1926
+ syncBackgroundJobsState();
1927
+ setTodoSession(session.sessionId);
1928
+ syncTodosState();
1760
1929
  await saveActiveSession();
1761
1930
  syncShellStateFromSession();
1762
1931
  store.update((next) => ({
@@ -1798,8 +1967,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1798
1967
  if (!queued || store.getState().busy || exiting) {
1799
1968
  return;
1800
1969
  }
1801
- // Rehydrate attachments/chunks before submit: handleSubmit expands
1802
- // pastedChunks from the store and the turn picks up imageAttachments.
1803
1970
  store.update((current) => ({
1804
1971
  ...current,
1805
1972
  imageAttachments: queued.imageAttachments,
@@ -1826,9 +1993,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1826
1993
  });
1827
1994
  return;
1828
1995
  }
1829
- // Snapshot the raw (placeholder) body plus chunks/images so recall and
1830
- // flush round-trip the collapsed paste + attachments. Hold at most one;
1831
- // a second enqueue replaces the slot.
1832
1996
  const pending = store.getState();
1833
1997
  const snapshot = {
1834
1998
  body: String(rawInput ?? ''),
@@ -1875,6 +2039,54 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1875
2039
  });
1876
2040
  return;
1877
2041
  }
2042
+ if (input === '/about') {
2043
+ appendStaticEntry({
2044
+ body: formatAboutCard(),
2045
+ kind: 'system',
2046
+ title: 'About',
2047
+ });
2048
+ return;
2049
+ }
2050
+ if (input === '/jobs' || input.startsWith('/jobs ')) {
2051
+ const jobsArgs = input.slice('/jobs'.length).trim();
2052
+ if (!jobsArgs) {
2053
+ openJobsPicker();
2054
+ return;
2055
+ }
2056
+ const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
2057
+ if (killMatch) {
2058
+ const jobId = killMatch[1];
2059
+ const killed = await killBackgroundJob(jobId);
2060
+ await collectBackgroundJobUiKillMutations({
2061
+ session,
2062
+ projectIndex,
2063
+ jobId,
2064
+ result: killed,
2065
+ });
2066
+ if (!killed.ok) {
2067
+ appendError(killed.error ?? 'Background job kill failed.');
2068
+ }
2069
+ else if (killed.alreadyFinished) {
2070
+ appendStaticEntry({
2071
+ body: `${jobId} had already finished.`,
2072
+ kind: 'system',
2073
+ title: 'Background jobs',
2074
+ });
2075
+ }
2076
+ return;
2077
+ }
2078
+ const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
2079
+ if (outputMatch) {
2080
+ await appendJobOutputEntry(outputMatch[1]);
2081
+ return;
2082
+ }
2083
+ appendStaticEntry({
2084
+ body: 'Usage: /jobs — open the jobs picker · /jobs output <id> — full output · /jobs kill <id> — kill one',
2085
+ kind: 'system',
2086
+ title: 'Background jobs',
2087
+ });
2088
+ return;
2089
+ }
1878
2090
  if (input === '/usage') {
1879
2091
  store.update((current) => ({
1880
2092
  ...current,
@@ -1950,6 +2162,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1950
2162
  }
1951
2163
  if (input === '/clear') {
1952
2164
  clearConversation(session);
2165
+ clearTodos();
2166
+ syncTodosState();
1953
2167
  latestUsageSummary = null;
1954
2168
  await saveActiveSession();
1955
2169
  store.replaceTranscript([
@@ -1971,6 +2185,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1971
2185
  const turnAbort = new AbortController();
1972
2186
  activeTurnAbort = turnAbort;
1973
2187
  lastTurnStartedAt = turnStartedAt;
2188
+ todosTouchedThisTurn = false;
1974
2189
  const userEntry = {
1975
2190
  body: input,
1976
2191
  kind: 'user',
@@ -2025,6 +2240,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2025
2240
  await appendSettledTurnEntries(turnEntries, result.text ?? '', () => turnGeneration === activeTurnGeneration && !turnAbort.signal.aborted);
2026
2241
  if (turnGeneration !== activeTurnGeneration)
2027
2242
  return;
2243
+ if (todosTouchedThisTurn) {
2244
+ todosTouchedThisTurn = false;
2245
+ const todoItems = listTodos();
2246
+ if (todoItems.length > 0) {
2247
+ appendStaticEntry({
2248
+ body: '',
2249
+ kind: 'system',
2250
+ title: `To-dos · ${formatTodoProgress(todoItems)}`,
2251
+ todoList: todoItems,
2252
+ });
2253
+ if (todoItems.every((item) => item.status === 'completed')) {
2254
+ clearTodos();
2255
+ syncTodosState();
2256
+ }
2257
+ }
2258
+ }
2028
2259
  store.update((current) => ({
2029
2260
  ...current,
2030
2261
  busy: false,
@@ -2073,8 +2304,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2073
2304
  appendError(error.message);
2074
2305
  }
2075
2306
  await remountTui();
2076
- // A cancelled turn never auto-submits the queue (Ctrl+C recalls it via
2077
- // cancelActiveTurn); only a genuine error flushes a pending message.
2078
2307
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2079
2308
  await flushQueuedMessage();
2080
2309
  }
@@ -2107,6 +2336,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2107
2336
  };
2108
2337
  session.onToolEvent = (event) => {
2109
2338
  syncShellStateFromSession();
2339
+ if (event.call.name === 'update_todos') {
2340
+ syncTodosState();
2341
+ if (event.result?.ok === true) {
2342
+ todosTouchedThisTurn = true;
2343
+ return;
2344
+ }
2345
+ }
2110
2346
  if (isFileChangeTool(event.call.name)) {
2111
2347
  const entry = buildFileChangeEntry(event);
2112
2348
  store.appendWorkingTool(entry);
@@ -2115,6 +2351,52 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2115
2351
  }
2116
2352
  store.appendWorkingTool(buildWorkingToolEntry(event));
2117
2353
  };
2354
+ const jobDetailLines = (jobId) => {
2355
+ const buffered = getJobBufferedOutput(jobId);
2356
+ if (!buffered)
2357
+ return [];
2358
+ const lines = buffered.output
2359
+ .replace(/\r\n/g, '\n')
2360
+ .replace(/\r/g, '\n')
2361
+ .split('\n')
2362
+ .filter((line) => line.trim().length > 0);
2363
+ const visible = lines.slice(-20);
2364
+ return buffered.droppedChars > 0
2365
+ ? [
2366
+ `... (${buffered.droppedChars} chars of older output dropped) ...`,
2367
+ ...visible,
2368
+ ]
2369
+ : visible;
2370
+ };
2371
+ const syncBackgroundJobsState = () => {
2372
+ const jobsForDisplay = listBackgroundJobs().map((job) => {
2373
+ const preview = getJobOutputPreview(job.id, 3);
2374
+ return {
2375
+ id: job.id,
2376
+ command: job.command,
2377
+ status: job.status,
2378
+ exitCode: job.exitCode,
2379
+ startedAt: job.startedAt,
2380
+ endedAt: job.endedAt,
2381
+ firstOutputLine: preview?.firstLine ?? '',
2382
+ tailLines: preview?.tailLines ?? [],
2383
+ detailLines: jobDetailLines(job.id),
2384
+ };
2385
+ });
2386
+ store.update((current) => ({
2387
+ ...current,
2388
+ backgroundJobs: jobsForDisplay,
2389
+ jobsPickerExpandedId: jobsForDisplay.some((job) => job.id === current.jobsPickerExpandedId)
2390
+ ? current.jobsPickerExpandedId
2391
+ : null,
2392
+ }));
2393
+ };
2394
+ setBackgroundJobUpdateHook((snapshot) => {
2395
+ syncBackgroundJobsState();
2396
+ if (snapshot.status !== 'running') {
2397
+ appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2398
+ }
2399
+ });
2118
2400
  projectIndex.onStatus = session.onStatus;
2119
2401
  projectIndex.onContextLog = session.onContextLog;
2120
2402
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
@@ -2174,6 +2456,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2174
2456
  onResumeSession: handleInlineResumeSelection,
2175
2457
  onSelectionCopy: handleAppSelectionCopy,
2176
2458
  onSelectModel: handleInlineModelSelection,
2459
+ onJobsPickerOutput: handleJobsPickerOutput,
2460
+ onJobsPickerKill: handleJobsPickerKill,
2177
2461
  onSudoPasswordInput: handleSudoPasswordInput,
2178
2462
  onSubmit: handleSubmit,
2179
2463
  };
@@ -2187,7 +2471,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2187
2471
  }
2188
2472
  }, 120);
2189
2473
  const clockTimer = setInterval(() => {
2190
- if (store.getState().busy) {
2474
+ if (hasRunningBackgroundJobs()) {
2475
+ syncBackgroundJobsState();
2476
+ }
2477
+ if (store.getState().busy || hasRunningBackgroundJobs()) {
2191
2478
  renderCurrentFrame();
2192
2479
  }
2193
2480
  }, 1000);
@@ -2231,6 +2518,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2231
2518
  clearInterval(clockTimer);
2232
2519
  unsubscribe();
2233
2520
  clearLiveFrameRemountTimer();
2521
+ killAllBackgroundJobs();
2522
+ setBackgroundJobSession(null);
2523
+ setTodoSession(null);
2524
+ setBackgroundJobUpdateHook(null);
2234
2525
  await bridge.close();
2235
2526
  setCommandOutputHook(null);
2236
2527
  }
@@ -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),
@@ -114,13 +111,13 @@ export function resolveTuiBinaryPath() {
114
111
  export function spawnTuiProcess(options = {}) {
115
112
  const binaryPath = resolveTuiBinaryPath();
116
113
  return spawn(binaryPath, [], {
117
- stdio: ['pipe', 'pipe', 'inherit'],
114
+ stdio: ['pipe', 'inherit', 'pipe'],
118
115
  env: { ...process.env, ...options.env },
119
116
  });
120
117
  }
121
118
  export function createRatatuiBridge() {
122
119
  const child = spawnTuiProcess();
123
- const rl = createInterface({ input: child.stdout });
120
+ const rl = createInterface({ input: child.stderr });
124
121
  let eventHandler = null;
125
122
  let closed = false;
126
123
  rl.on('line', (line) => {
@@ -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', () => {