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

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
  };
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
+ import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { BINARY_ARTIFACT_EXTENSIONS } from './artifact-policy.js';
6
6
  import { resolveProjectPath } from './patcher.js';
@@ -30,13 +30,33 @@ function snapshotEncoding(filePath, content) {
30
30
  return 'base64';
31
31
  return 'utf8';
32
32
  }
33
+ const MAX_SNAPSHOT_READ_BYTES = MAX_STORED_CONTENT_CHARS;
34
+ const SNAPSHOT_HASH_CHUNK_BYTES = 1024 * 1024;
35
+ function hashFileInChunks(absPath) {
36
+ const hash = createHash('sha256');
37
+ const chunk = Buffer.allocUnsafe(SNAPSHOT_HASH_CHUNK_BYTES);
38
+ const fd = openSync(absPath, 'r');
39
+ try {
40
+ for (;;) {
41
+ const read = readSync(fd, chunk, 0, chunk.length, null);
42
+ if (read <= 0)
43
+ break;
44
+ hash.update(chunk.subarray(0, read));
45
+ }
46
+ }
47
+ finally {
48
+ closeSync(fd);
49
+ }
50
+ return `sha256:${hash.digest('hex')}`;
51
+ }
33
52
  export function readFileEditSnapshot(rootDir, filePath) {
34
53
  try {
35
54
  const absPath = resolveProjectPath(rootDir, filePath);
36
55
  if (!existsSync(absPath)) {
37
56
  return { exists: false, content: null, contentEncoding: 'utf8', hash: null };
38
57
  }
39
- if (lstatSync(absPath).isSymbolicLink()) {
58
+ const stat = lstatSync(absPath);
59
+ if (stat.isSymbolicLink()) {
40
60
  return {
41
61
  exists: true,
42
62
  content: null,
@@ -45,6 +65,17 @@ export function readFileEditSnapshot(rootDir, filePath) {
45
65
  error: `Refusing to snapshot symbolic link: ${filePath}`,
46
66
  };
47
67
  }
68
+ if (stat.isFile() && stat.size > MAX_SNAPSHOT_READ_BYTES) {
69
+ return {
70
+ exists: true,
71
+ content: null,
72
+ contentEncoding: BINARY_ARTIFACT_EXTENSIONS.has(path.extname(filePath).toLowerCase())
73
+ ? 'base64'
74
+ : 'utf8',
75
+ hash: hashFileInChunks(absPath),
76
+ error: `File is too large to snapshot (${stat.size} bytes).`,
77
+ };
78
+ }
48
79
  const content = readFileSync(absPath);
49
80
  const encoding = snapshotEncoding(filePath, content);
50
81
  return {
@@ -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.',
@@ -1,5 +1,5 @@
1
1
  import chalk from './colors.js';
2
- import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from 'fs';
2
+ import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, unlinkSync, writeFileSync, } from 'fs';
3
3
  import path from 'path';
4
4
  import { createInterface } from 'readline';
5
5
  import { runCommand } from './executor.js';
@@ -271,22 +271,61 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
271
271
  }
272
272
  return { absPath, changed: true };
273
273
  }
274
+ const MAX_DELETED_CONTENT_BYTES = 64 * 1024;
275
+ const DELETED_BINARY_SNIFF_BYTES = 4096;
276
+ function headHasNulByte(absPath) {
277
+ const chunk = Buffer.allocUnsafe(DELETED_BINARY_SNIFF_BYTES);
278
+ let fd = null;
279
+ try {
280
+ fd = openSync(absPath, 'r');
281
+ const read = readSync(fd, chunk, 0, chunk.length, 0);
282
+ return read > 0 && chunk.subarray(0, read).includes(0);
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ finally {
288
+ if (fd !== null)
289
+ closeSync(fd);
290
+ }
291
+ }
274
292
  export function deleteProjectFile(rootDir, filePath) {
275
293
  const absPath = resolveProjectPath(rootDir, filePath);
276
294
  if (!existsSync(absPath)) {
277
295
  return { deleted: false, absPath };
278
296
  }
297
+ let bytes;
279
298
  let content;
299
+ let contentOmitted;
280
300
  try {
281
- if (!lstatSync(absPath).isSymbolicLink()) {
282
- content = readFileSync(absPath, 'utf-8');
301
+ const stat = lstatSync(absPath);
302
+ if (!stat.isSymbolicLink()) {
303
+ bytes = stat.size;
304
+ if (stat.size > MAX_DELETED_CONTENT_BYTES) {
305
+ contentOmitted = headHasNulByte(absPath) ? 'binary' : 'too_large';
306
+ }
307
+ else {
308
+ const buffer = readFileSync(absPath);
309
+ if (buffer.includes(0))
310
+ contentOmitted = 'binary';
311
+ else
312
+ content = buffer.toString('utf-8');
313
+ }
283
314
  }
284
315
  }
285
316
  catch {
317
+ bytes = undefined;
286
318
  content = undefined;
319
+ contentOmitted = undefined;
287
320
  }
288
321
  unlinkSync(absPath);
289
- return { deleted: true, absPath, content };
322
+ return {
323
+ deleted: true,
324
+ absPath,
325
+ ...(bytes === undefined ? {} : { bytes }),
326
+ ...(content === undefined ? {} : { content }),
327
+ ...(contentOmitted === undefined ? {} : { contentOmitted }),
328
+ };
290
329
  }
291
330
  export function readProjectFile(rootDir, filePath) {
292
331
  const absPath = resolveProjectPath(rootDir, filePath);
@@ -51,7 +51,11 @@ export async function deleteFile(context, args) {
51
51
  changed: result.deleted,
52
52
  deleted: result.deleted,
53
53
  ...(scratchPath ? { scratch: true } : {}),
54
- content: result.content,
54
+ ...(result.bytes === undefined ? {} : { bytes: result.bytes }),
55
+ ...(result.contentOmitted === undefined
56
+ ? {}
57
+ : { contentOmitted: result.contentOmitted }),
58
+ ...(result.content === undefined ? {} : { content: result.content }),
55
59
  diagnostics: result.deleted && !scratchPath ? runShellDiagnostics(rootDir) : undefined,
56
60
  };
57
61
  }
@@ -527,11 +527,43 @@ function buildWriteFileDiff(content) {
527
527
  return '';
528
528
  return `@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}`).join('\n')}`;
529
529
  }
530
+ const MAX_DELETE_PREVIEW_LINES = 400;
530
531
  function buildDeleteFileDiff(content) {
531
532
  const lines = splitDiffLines(String(content ?? ''));
532
- if (lines.length === 0)
533
+ if (lines.length === 0) {
534
+ return { removed: 0, text: '' };
535
+ }
536
+ const shown = lines.slice(0, MAX_DELETE_PREVIEW_LINES);
537
+ const rows = [
538
+ `@@ -1,${lines.length} +0,0 @@`,
539
+ ...shown.map((line) => `-${line}`),
540
+ ];
541
+ const omitted = lines.length - shown.length;
542
+ if (omitted > 0) {
543
+ rows.push(`@@ ${omitted} more removed line(s) not shown @@`);
544
+ }
545
+ return { removed: lines.length, text: rows.join('\n') };
546
+ }
547
+ function deletedFileSummary(result) {
548
+ const bytes = typeof result?.bytes === 'number' ? result.bytes : null;
549
+ if (bytes === null) {
533
550
  return '';
534
- return `@@ -1,${lines.length} +0,0 @@\n${lines.map((line) => `-${line}`).join('\n')}`;
551
+ }
552
+ const reason = result?.contentOmitted === 'binary' ? ', binary' : '';
553
+ return ` (${formatFileSize(bytes)}${reason})`;
554
+ }
555
+ function formatFileSize(bytes) {
556
+ if (bytes < 1024) {
557
+ return `${bytes} B`;
558
+ }
559
+ const units = ['KiB', 'MiB', 'GiB', 'TiB'];
560
+ let value = bytes / 1024;
561
+ let unit = 0;
562
+ while (value >= 1024 && unit < units.length - 1) {
563
+ value /= 1024;
564
+ unit += 1;
565
+ }
566
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
535
567
  }
536
568
  function isFileChangeTool(name) {
537
569
  return (name === 'patch_file' ||
@@ -659,9 +691,11 @@ function buildFileChangeEntry(event) {
659
691
  };
660
692
  }
661
693
  const content = typeof result?.content === 'string' ? result.content : '';
662
- const diffText = buildDeleteFileDiff(content);
663
- const preview = diffText ? parseDiffPreview(diffText) : undefined;
664
- const summary = preview ? ` (+0 -${preview.removed})` : '';
694
+ const diff = buildDeleteFileDiff(content);
695
+ const preview = diff.text ? parseDiffPreview(diff.text) : undefined;
696
+ const summary = preview
697
+ ? ` (+0 -${diff.removed})`
698
+ : deletedFileSummary(result);
665
699
  return {
666
700
  body: '',
667
701
  diffPreview: preview,
@@ -923,6 +957,23 @@ function createSessionTranscript(session) {
923
957
  const transcript = buildTranscriptFromSessionHistory(session.history);
924
958
  return transcript.length ? transcript : createInitialTranscript();
925
959
  }
960
+ export function isJobsPickerCommand(input) {
961
+ return String(input ?? '').trim() === '/jobs';
962
+ }
963
+ export function statusBehindJobsPicker(state) {
964
+ return state.jobsPickerOpen
965
+ ? (state.jobsPickerReturnStatus ?? 'Ready')
966
+ : state.status;
967
+ }
968
+ export function closedJobsPickerFields(state) {
969
+ if (!state.jobsPickerOpen)
970
+ return {};
971
+ return {
972
+ jobsPickerExpandedId: null,
973
+ jobsPickerOpen: false,
974
+ jobsPickerReturnStatus: null,
975
+ };
976
+ }
926
977
  function createShellStore(initialState) {
927
978
  let state = initialState;
928
979
  let nextEntryId = 1;
@@ -1941,8 +1992,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1941
1992
  signal?.addEventListener('abort', onAbort, { once: true });
1942
1993
  store.update((current) => ({
1943
1994
  ...pauseBusyClock(current, Date.now()),
1995
+ ...closedJobsPickerFields(current),
1944
1996
  status: 'Waiting for your input',
1945
- userInputPrompt: createUserInputPromptState(questions, current.status),
1997
+ userInputPrompt: createUserInputPromptState(questions, statusBehindJobsPicker(current)),
1946
1998
  }));
1947
1999
  scheduleLiveFrameRemount();
1948
2000
  });
@@ -1975,6 +2027,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1975
2027
  resolvePermissionDecision = resolve;
1976
2028
  store.update((current) => ({
1977
2029
  ...pauseBusyClock(current, Date.now()),
2030
+ ...closedJobsPickerFields(current),
1978
2031
  approvalCursor: getDefaultApprovalCursor(request.options.length),
1979
2032
  approvalOpenedAt: Date.now(),
1980
2033
  approvalScrollOffset: 0,
@@ -1986,7 +2039,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1986
2039
  : undefined,
1987
2040
  filePath: request.filePath,
1988
2041
  options: request.options,
1989
- returnStatus: current.status,
2042
+ returnStatus: statusBehindJobsPicker(current),
1990
2043
  },
1991
2044
  status: request.title,
1992
2045
  }));
@@ -2048,17 +2101,19 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2048
2101
  });
2049
2102
  };
2050
2103
  const openJobsPicker = () => {
2104
+ if (store.getState().jobsPickerOpen)
2105
+ return;
2051
2106
  syncBackgroundJobsState();
2052
2107
  store.update((current) => ({
2053
2108
  ...current,
2054
2109
  commandCursor: 0,
2055
- cursor: 0,
2056
- input: '',
2057
2110
  jobsPickerExpandedId: null,
2058
2111
  jobsPickerIndex: 0,
2059
2112
  jobsPickerOpen: true,
2113
+ jobsPickerReturnStatus: current.status,
2060
2114
  status: 'Background jobs',
2061
2115
  }));
2116
+ scheduleLiveFrameRemount();
2062
2117
  };
2063
2118
  const appendJobOutputEntry = async (jobId) => {
2064
2119
  const id = jobId.trim();
@@ -2089,30 +2144,51 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2089
2144
  const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
2090
2145
  return current.backgroundJobs[index]?.id ?? null;
2091
2146
  };
2147
+ const deferredJobMutationIds = new Set();
2148
+ const drainDeferredJobMutations = async () => {
2149
+ if (deferredJobMutationIds.size === 0)
2150
+ return;
2151
+ const pending = [...deferredJobMutationIds];
2152
+ deferredJobMutationIds.clear();
2153
+ for (const jobId of pending) {
2154
+ await collectBackgroundJobUiOutputMutations({ session, jobId });
2155
+ }
2156
+ syncBackgroundJobsState();
2157
+ };
2092
2158
  const handleJobsPickerOutput = async () => {
2093
2159
  const jobId = selectedJobsPickerId();
2094
2160
  if (!jobId)
2095
2161
  return;
2096
- await collectBackgroundJobUiOutputMutations({
2097
- session,
2098
- jobId,
2099
- });
2100
- syncBackgroundJobsState();
2162
+ if (store.getState().busy) {
2163
+ deferredJobMutationIds.add(jobId);
2164
+ }
2165
+ else {
2166
+ await collectBackgroundJobUiOutputMutations({
2167
+ session,
2168
+ jobId,
2169
+ });
2170
+ }
2101
2171
  store.update((current) => ({
2102
2172
  ...current,
2103
2173
  jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
2104
2174
  }));
2175
+ syncBackgroundJobsState();
2105
2176
  };
2106
2177
  const handleJobsPickerKill = async () => {
2107
2178
  const jobId = selectedJobsPickerId();
2108
2179
  if (!jobId)
2109
2180
  return;
2110
2181
  const killed = await killBackgroundJob(jobId);
2111
- await collectBackgroundJobUiKillMutations({
2112
- session,
2113
- jobId,
2114
- result: killed,
2115
- });
2182
+ if (store.getState().busy) {
2183
+ deferredJobMutationIds.add(jobId);
2184
+ }
2185
+ else {
2186
+ await collectBackgroundJobUiKillMutations({
2187
+ session,
2188
+ jobId,
2189
+ result: killed,
2190
+ });
2191
+ }
2116
2192
  syncBackgroundJobsState();
2117
2193
  if (!killed.ok) {
2118
2194
  appendError(killed.error ?? 'Background job kill failed.');
@@ -2404,8 +2480,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2404
2480
  if (!input || exiting)
2405
2481
  return;
2406
2482
  if (input.startsWith('/')) {
2483
+ if (isJobsPickerCommand(input)) {
2484
+ store.update((current) => ({
2485
+ ...current,
2486
+ cursor: 0,
2487
+ input: '',
2488
+ pastedChunks: [],
2489
+ }));
2490
+ openJobsPicker();
2491
+ return;
2492
+ }
2407
2493
  appendTurnAwareEntry({
2408
- body: "Slash commands can't be queued while a turn is running.",
2494
+ body: "Slash commands can't be queued while a turn is running. Ctrl+B opens background jobs.",
2409
2495
  kind: 'system',
2410
2496
  title: 'Queued',
2411
2497
  });
@@ -2845,6 +2931,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2845
2931
  activeServerTurnId = null;
2846
2932
  }
2847
2933
  recoverUnacknowledgedMessages();
2934
+ await drainDeferredJobMutations();
2848
2935
  }
2849
2936
  };
2850
2937
  session.onImageAnalysis = (activeImageCount) => {
@@ -2897,26 +2984,20 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2897
2984
  }
2898
2985
  store.appendWorkingTool(buildWorkingToolEntry(event));
2899
2986
  };
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
2987
  const syncBackgroundJobsState = () => {
2988
+ const expandedId = store.getState().jobsPickerExpandedId;
2918
2989
  const jobsForDisplay = listBackgroundJobs().map((job) => {
2919
- const preview = getJobOutputPreview(job.id, 3);
2990
+ const expanded = job.id === expandedId;
2991
+ const preview = getJobOutputPreview(job.id, expanded ? 20 : 3);
2992
+ const dropped = preview?.droppedChars ?? 0;
2993
+ const detailLines = !expanded
2994
+ ? []
2995
+ : dropped > 0
2996
+ ? [
2997
+ `... (${dropped} chars of older output dropped) ...`,
2998
+ ...(preview?.tailLines ?? []),
2999
+ ]
3000
+ : (preview?.tailLines ?? []);
2920
3001
  return {
2921
3002
  id: job.id,
2922
3003
  command: job.command,
@@ -2925,8 +3006,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2925
3006
  startedAt: job.startedAt,
2926
3007
  endedAt: job.endedAt,
2927
3008
  firstOutputLine: preview?.firstLine ?? '',
2928
- tailLines: preview?.tailLines ?? [],
2929
- detailLines: jobDetailLines(job.id),
3009
+ tailLines: (preview?.tailLines ?? []).slice(-3),
3010
+ detailLines,
2930
3011
  };
2931
3012
  });
2932
3013
  store.update((current) => ({
@@ -2996,6 +3077,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2996
3077
  onResumeSession: handleInlineResumeSelection,
2997
3078
  onSelectionCopy: handleAppSelectionCopy,
2998
3079
  onSelectModel: handleInlineModelSelection,
3080
+ onOpenJobsPicker: openJobsPicker,
2999
3081
  onJobsPickerOutput: handleJobsPickerOutput,
3000
3082
  onJobsPickerKill: handleJobsPickerKill,
3001
3083
  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.37",
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.37",
48
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.37",
49
+ "@thegitai/tui-linux-arm64": "1.0.0-preview.37",
50
+ "@thegitai/tui-linux-x64": "1.0.0-preview.37",
51
+ "@thegitai/tui-win32-x64": "1.0.0-preview.37",
52
52
  "@vscode/ripgrep": "1.18.0"
53
53
  },
54
54
  "repository": {