@thegitai/cli 1.0.0-preview.35 → 1.0.0-preview.36

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.
@@ -265,6 +265,7 @@ export function getJobOutputTail(id, maxLines) {
265
265
  if (!record || !belongsToSession(record) || maxLines <= 0)
266
266
  return [];
267
267
  const lines = sanitizeJobText(record, record.buffer)
268
+ .replace(/\r\n?/g, '\n')
268
269
  .split('\n')
269
270
  .filter((line) => line.trim().length > 0);
270
271
  return lines.slice(-maxLines);
@@ -277,6 +278,7 @@ export function getJobOutputPreview(id, maxTailLines) {
277
278
  ? sanitizeJobText(record, record.firstOutputLine).trim()
278
279
  : '';
279
280
  return {
281
+ droppedChars: Math.max(record.totalCaptured - record.buffer.length, 0),
280
282
  firstLine,
281
283
  tailLines: getJobOutputTail(id, maxTailLines),
282
284
  };
@@ -92,6 +92,8 @@ const HELP_MARKDOWN = [
92
92
  '- `/resume` — resume a saved session for this repo',
93
93
  '- `/jobs` — manage long-running commands like dev servers and watchers:',
94
94
  ' browse them, press Enter to expand one and read its output, k to stop it',
95
+ '- Ctrl+B — open the same jobs picker, and the only way in while the agent',
96
+ ' is working; press it again or Esc to close without touching the turn',
95
97
  '- `/jobs output <id>` — print one job\'s full captured output',
96
98
  '- `/jobs kill <id>` — stop one background job',
97
99
  '- `/new` — start a new conversation; this session remains saved',
@@ -130,8 +132,9 @@ const HELP_MARKDOWN = [
130
132
  ' jobs after the same approval as any other command. Background output',
131
133
  ' stays quiet once the model has responded; the footer shows only a compact',
132
134
  ' shell-running indicator, `/jobs` lists, inspects, and kills jobs, and',
133
- ' killed jobs disappear immediately. Every job is killed when the session',
134
- ' ends.',
135
+ ' killed jobs disappear immediately. While a turn is running the indicator',
136
+ ' points at Ctrl+B instead, which opens the same picker without cancelling',
137
+ ' or interrupting the turn. Every job is killed when the session ends.',
135
138
  '- File and shell operations are confined to the target repo root.',
136
139
  '- Sensitive directories (`.git`, `node_modules`, build output) are',
137
140
  ' excluded from search and listing.',
@@ -923,6 +923,23 @@ function createSessionTranscript(session) {
923
923
  const transcript = buildTranscriptFromSessionHistory(session.history);
924
924
  return transcript.length ? transcript : createInitialTranscript();
925
925
  }
926
+ export function isJobsPickerCommand(input) {
927
+ return String(input ?? '').trim() === '/jobs';
928
+ }
929
+ export function statusBehindJobsPicker(state) {
930
+ return state.jobsPickerOpen
931
+ ? (state.jobsPickerReturnStatus ?? 'Ready')
932
+ : state.status;
933
+ }
934
+ export function closedJobsPickerFields(state) {
935
+ if (!state.jobsPickerOpen)
936
+ return {};
937
+ return {
938
+ jobsPickerExpandedId: null,
939
+ jobsPickerOpen: false,
940
+ jobsPickerReturnStatus: null,
941
+ };
942
+ }
926
943
  function createShellStore(initialState) {
927
944
  let state = initialState;
928
945
  let nextEntryId = 1;
@@ -1941,8 +1958,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1941
1958
  signal?.addEventListener('abort', onAbort, { once: true });
1942
1959
  store.update((current) => ({
1943
1960
  ...pauseBusyClock(current, Date.now()),
1961
+ ...closedJobsPickerFields(current),
1944
1962
  status: 'Waiting for your input',
1945
- userInputPrompt: createUserInputPromptState(questions, current.status),
1963
+ userInputPrompt: createUserInputPromptState(questions, statusBehindJobsPicker(current)),
1946
1964
  }));
1947
1965
  scheduleLiveFrameRemount();
1948
1966
  });
@@ -1975,6 +1993,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1975
1993
  resolvePermissionDecision = resolve;
1976
1994
  store.update((current) => ({
1977
1995
  ...pauseBusyClock(current, Date.now()),
1996
+ ...closedJobsPickerFields(current),
1978
1997
  approvalCursor: getDefaultApprovalCursor(request.options.length),
1979
1998
  approvalOpenedAt: Date.now(),
1980
1999
  approvalScrollOffset: 0,
@@ -1986,7 +2005,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1986
2005
  : undefined,
1987
2006
  filePath: request.filePath,
1988
2007
  options: request.options,
1989
- returnStatus: current.status,
2008
+ returnStatus: statusBehindJobsPicker(current),
1990
2009
  },
1991
2010
  status: request.title,
1992
2011
  }));
@@ -2048,17 +2067,19 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2048
2067
  });
2049
2068
  };
2050
2069
  const openJobsPicker = () => {
2070
+ if (store.getState().jobsPickerOpen)
2071
+ return;
2051
2072
  syncBackgroundJobsState();
2052
2073
  store.update((current) => ({
2053
2074
  ...current,
2054
2075
  commandCursor: 0,
2055
- cursor: 0,
2056
- input: '',
2057
2076
  jobsPickerExpandedId: null,
2058
2077
  jobsPickerIndex: 0,
2059
2078
  jobsPickerOpen: true,
2079
+ jobsPickerReturnStatus: current.status,
2060
2080
  status: 'Background jobs',
2061
2081
  }));
2082
+ scheduleLiveFrameRemount();
2062
2083
  };
2063
2084
  const appendJobOutputEntry = async (jobId) => {
2064
2085
  const id = jobId.trim();
@@ -2089,30 +2110,51 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2089
2110
  const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
2090
2111
  return current.backgroundJobs[index]?.id ?? null;
2091
2112
  };
2113
+ const deferredJobMutationIds = new Set();
2114
+ const drainDeferredJobMutations = async () => {
2115
+ if (deferredJobMutationIds.size === 0)
2116
+ return;
2117
+ const pending = [...deferredJobMutationIds];
2118
+ deferredJobMutationIds.clear();
2119
+ for (const jobId of pending) {
2120
+ await collectBackgroundJobUiOutputMutations({ session, jobId });
2121
+ }
2122
+ syncBackgroundJobsState();
2123
+ };
2092
2124
  const handleJobsPickerOutput = async () => {
2093
2125
  const jobId = selectedJobsPickerId();
2094
2126
  if (!jobId)
2095
2127
  return;
2096
- await collectBackgroundJobUiOutputMutations({
2097
- session,
2098
- jobId,
2099
- });
2100
- syncBackgroundJobsState();
2128
+ if (store.getState().busy) {
2129
+ deferredJobMutationIds.add(jobId);
2130
+ }
2131
+ else {
2132
+ await collectBackgroundJobUiOutputMutations({
2133
+ session,
2134
+ jobId,
2135
+ });
2136
+ }
2101
2137
  store.update((current) => ({
2102
2138
  ...current,
2103
2139
  jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
2104
2140
  }));
2141
+ syncBackgroundJobsState();
2105
2142
  };
2106
2143
  const handleJobsPickerKill = async () => {
2107
2144
  const jobId = selectedJobsPickerId();
2108
2145
  if (!jobId)
2109
2146
  return;
2110
2147
  const killed = await killBackgroundJob(jobId);
2111
- await collectBackgroundJobUiKillMutations({
2112
- session,
2113
- jobId,
2114
- result: killed,
2115
- });
2148
+ if (store.getState().busy) {
2149
+ deferredJobMutationIds.add(jobId);
2150
+ }
2151
+ else {
2152
+ await collectBackgroundJobUiKillMutations({
2153
+ session,
2154
+ jobId,
2155
+ result: killed,
2156
+ });
2157
+ }
2116
2158
  syncBackgroundJobsState();
2117
2159
  if (!killed.ok) {
2118
2160
  appendError(killed.error ?? 'Background job kill failed.');
@@ -2404,8 +2446,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2404
2446
  if (!input || exiting)
2405
2447
  return;
2406
2448
  if (input.startsWith('/')) {
2449
+ if (isJobsPickerCommand(input)) {
2450
+ store.update((current) => ({
2451
+ ...current,
2452
+ cursor: 0,
2453
+ input: '',
2454
+ pastedChunks: [],
2455
+ }));
2456
+ openJobsPicker();
2457
+ return;
2458
+ }
2407
2459
  appendTurnAwareEntry({
2408
- body: "Slash commands can't be queued while a turn is running.",
2460
+ body: "Slash commands can't be queued while a turn is running. Ctrl+B opens background jobs.",
2409
2461
  kind: 'system',
2410
2462
  title: 'Queued',
2411
2463
  });
@@ -2845,6 +2897,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2845
2897
  activeServerTurnId = null;
2846
2898
  }
2847
2899
  recoverUnacknowledgedMessages();
2900
+ await drainDeferredJobMutations();
2848
2901
  }
2849
2902
  };
2850
2903
  session.onImageAnalysis = (activeImageCount) => {
@@ -2897,26 +2950,20 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2897
2950
  }
2898
2951
  store.appendWorkingTool(buildWorkingToolEntry(event));
2899
2952
  };
2900
- const jobDetailLines = (jobId) => {
2901
- const buffered = getJobBufferedOutput(jobId);
2902
- if (!buffered)
2903
- return [];
2904
- const lines = buffered.output
2905
- .replace(/\r\n/g, '\n')
2906
- .replace(/\r/g, '\n')
2907
- .split('\n')
2908
- .filter((line) => line.trim().length > 0);
2909
- const visible = lines.slice(-20);
2910
- return buffered.droppedChars > 0
2911
- ? [
2912
- `... (${buffered.droppedChars} chars of older output dropped) ...`,
2913
- ...visible,
2914
- ]
2915
- : visible;
2916
- };
2917
2953
  const syncBackgroundJobsState = () => {
2954
+ const expandedId = store.getState().jobsPickerExpandedId;
2918
2955
  const jobsForDisplay = listBackgroundJobs().map((job) => {
2919
- const preview = getJobOutputPreview(job.id, 3);
2956
+ const expanded = job.id === expandedId;
2957
+ const preview = getJobOutputPreview(job.id, expanded ? 20 : 3);
2958
+ const dropped = preview?.droppedChars ?? 0;
2959
+ const detailLines = !expanded
2960
+ ? []
2961
+ : dropped > 0
2962
+ ? [
2963
+ `... (${dropped} chars of older output dropped) ...`,
2964
+ ...(preview?.tailLines ?? []),
2965
+ ]
2966
+ : (preview?.tailLines ?? []);
2920
2967
  return {
2921
2968
  id: job.id,
2922
2969
  command: job.command,
@@ -2925,8 +2972,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2925
2972
  startedAt: job.startedAt,
2926
2973
  endedAt: job.endedAt,
2927
2974
  firstOutputLine: preview?.firstLine ?? '',
2928
- tailLines: preview?.tailLines ?? [],
2929
- detailLines: jobDetailLines(job.id),
2975
+ tailLines: (preview?.tailLines ?? []).slice(-3),
2976
+ detailLines,
2930
2977
  };
2931
2978
  });
2932
2979
  store.update((current) => ({
@@ -2996,6 +3043,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2996
3043
  onResumeSession: handleInlineResumeSelection,
2997
3044
  onSelectionCopy: handleAppSelectionCopy,
2998
3045
  onSelectModel: handleInlineModelSelection,
3046
+ onOpenJobsPicker: openJobsPicker,
2999
3047
  onJobsPickerOutput: handleJobsPickerOutput,
3000
3048
  onJobsPickerKill: handleJobsPickerKill,
3001
3049
  onSudoPasswordInput: handleSudoPasswordInput,
@@ -454,10 +454,11 @@ function backgroundJobIndicatorSpans(state) {
454
454
  if (runningCount === 0)
455
455
  return [];
456
456
  const noun = runningCount === 1 ? 'shell' : 'shells';
457
+ const hint = state.busy ? ' · ctrl+b' : ' · /jobs';
457
458
  return [
458
459
  span(' ', { color: 'gray', dim: true }),
459
460
  span(`● ${runningCount} ${noun} running`, { color: 'green', bold: true }),
460
- span(' · /jobs', { color: 'gray', dim: true }),
461
+ span(hint, { color: 'gray', dim: true }),
461
462
  ];
462
463
  }
463
464
  function todoIndicatorSpans(state) {
@@ -579,15 +580,29 @@ function jobStatusDescriptor(job, nowMs) {
579
580
  ? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
580
581
  : { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
581
582
  }
582
- function buildJobsPickerLines(state, width, nowMs) {
583
+ const JOBS_PICKER_CHROME_ROWS = 2;
584
+ const JOBS_PICKER_MIN_ROWS = 8;
585
+ function buildJobsPickerLines(state, width, height, nowMs) {
583
586
  const jobs = state.backgroundJobs ?? [];
587
+ const bodyBudget = Math.max(1, height - JOBS_PICKER_CHROME_ROWS);
584
588
  const lines = [
585
589
  plainLine('Background jobs', { color: 'cyan', bold: true }),
586
590
  ];
587
591
  if (jobs.length === 0) {
588
592
  lines.push(plainLine('No background jobs in this session.', { color: 'gray' }));
589
593
  }
590
- for (const [index, job] of jobs.entries()) {
594
+ let start = 0;
595
+ let visible = jobs;
596
+ if (jobs.length > bodyBudget) {
597
+ start = Math.min(Math.max(0, state.jobsPickerIndex - Math.floor(bodyBudget / 2)), jobs.length - bodyBudget);
598
+ visible = jobs.slice(start, start + bodyBudget);
599
+ if (start > 0) {
600
+ lines.push(plainLine(` … ${start} newer`, { color: 'gray', dim: true }));
601
+ }
602
+ }
603
+ const detailBudget = Math.max(0, bodyBudget - visible.length - (start > 0 ? 1 : 0));
604
+ for (const [offset, job] of visible.entries()) {
605
+ const index = start + offset;
591
606
  const selected = index === state.jobsPickerIndex;
592
607
  const expanded = state.jobsPickerExpandedId === job.id;
593
608
  const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
@@ -596,10 +611,11 @@ function buildJobsPickerLines(state, width, nowMs) {
596
611
  bold: selected,
597
612
  }), span(` ${text}`, { color: 'gray' })));
598
613
  if (expanded) {
599
- const detailLines = (job.detailLines ?? []).length
614
+ const allDetail = (job.detailLines ?? []).length
600
615
  ? job.detailLines ?? []
601
616
  : backgroundJobPreviewLines(job, 3);
602
- if (detailLines.length === 0) {
617
+ const detailLines = allDetail.slice(-detailBudget);
618
+ if (detailBudget > 0 && detailLines.length === 0) {
603
619
  lines.push(plainLine(' (no output captured)', { color: 'gray' }));
604
620
  }
605
621
  else {
@@ -612,6 +628,10 @@ function buildJobsPickerLines(state, width, nowMs) {
612
628
  }
613
629
  }
614
630
  }
631
+ const remaining = jobs.length - (start + visible.length);
632
+ if (remaining > 0) {
633
+ lines.push(plainLine(` … ${remaining} older`, { color: 'gray', dim: true }));
634
+ }
615
635
  lines.push(plainLine('↑/↓ move enter expand/collapse k kill esc close', {
616
636
  color: 'gray',
617
637
  }));
@@ -1119,7 +1139,7 @@ function buildOverlayLines(state, width, height, nowMs) {
1119
1139
  lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
1120
1140
  }
1121
1141
  if (state.jobsPickerOpen) {
1122
- lines.push(...buildJobsPickerLines(state, width, nowMs));
1142
+ lines.push(...buildJobsPickerLines(state, width, height, nowMs));
1123
1143
  }
1124
1144
  if (state.resumePickerOpen) {
1125
1145
  const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
@@ -1259,7 +1279,9 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1259
1279
  }
1260
1280
  const overlayHeight = state.userInputPrompt
1261
1281
  ? Math.max(0, rows - countSectionLines(sections))
1262
- : rows;
1282
+ : state.jobsPickerOpen
1283
+ ? Math.max(Math.min(rows, JOBS_PICKER_MIN_ROWS), rows - countSectionLines(sections))
1284
+ : rows;
1263
1285
  const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
1264
1286
  if (overlayLines.length > 0) {
1265
1287
  sections.push({ kind: 'overlay', lines: overlayLines });
@@ -320,12 +320,14 @@ export function handleShellKeyEvent(store, handlers, event) {
320
320
  return;
321
321
  }
322
322
  if (state.jobsPickerOpen) {
323
- if (key.escape) {
323
+ if (key.escape || (key.ctrl && key.input === 'b' && !key.meta && !key.shift)) {
324
+ handlers.onLiveFrameShapeChange();
324
325
  store.update((current) => ({
325
326
  ...current,
326
327
  jobsPickerExpandedId: null,
327
328
  jobsPickerOpen: false,
328
- status: 'Ready',
329
+ jobsPickerReturnStatus: null,
330
+ status: current.jobsPickerReturnStatus ?? 'Ready',
329
331
  }));
330
332
  return;
331
333
  }
@@ -413,6 +415,12 @@ export function handleShellKeyEvent(store, handlers, event) {
413
415
  }
414
416
  return;
415
417
  }
418
+ if (key.ctrl && key.input === 'b' && !key.meta && !key.shift) {
419
+ if ((state.backgroundJobs ?? []).length > 0) {
420
+ handlers.onOpenJobsPicker();
421
+ }
422
+ return;
423
+ }
416
424
  if (key.escape) {
417
425
  if (state.busy) {
418
426
  if (state.queuedMessage) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.35",
3
+ "version": "1.0.0-preview.36",
4
4
  "description": "TheGitAI is an agentic AI coding tool for your terminal. It reads and searches your repository, writes and edits files, runs your tests, and verifies the change before handing it back.",
5
5
  "keywords": [
6
6
  "agentic-ai",
@@ -44,11 +44,11 @@
44
44
  "@lydell/node-pty-linux-x64": "1.1.0",
45
45
  "@lydell/node-pty-win32-arm64": "1.1.0",
46
46
  "@lydell/node-pty-win32-x64": "1.1.0",
47
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.35",
48
- "@thegitai/tui-darwin-x64": "1.0.0-preview.35",
49
- "@thegitai/tui-linux-arm64": "1.0.0-preview.35",
50
- "@thegitai/tui-linux-x64": "1.0.0-preview.35",
51
- "@thegitai/tui-win32-x64": "1.0.0-preview.35",
47
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.36",
48
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.36",
49
+ "@thegitai/tui-linux-arm64": "1.0.0-preview.36",
50
+ "@thegitai/tui-linux-x64": "1.0.0-preview.36",
51
+ "@thegitai/tui-win32-x64": "1.0.0-preview.36",
52
52
  "@vscode/ripgrep": "1.18.0"
53
53
  },
54
54
  "repository": {