@thegitai/cli 1.0.0-preview.34 → 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.
@@ -7,6 +7,7 @@ import { isUserInputQuestionArray } from './contracts.js';
7
7
  import { createTraceContext, gatewayFailureCategory, normalizeServerUrl, readErrorResponse, } from './http.js';
8
8
  import { collectClientEnvironment } from '../client-environment.js';
9
9
  import { collectProjectOrientation } from '../project-orientation.js';
10
+ import { describeSessionImageStore } from '../core/session-image-store.js';
10
11
  import { autoAttachImages } from '../core/image-path-extractor.js';
11
12
  import { formatTurnFailureMarker } from '../turn-failure-marker.js';
12
13
  export class TurnCancelledError extends Error {
@@ -546,6 +547,7 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
546
547
  backgroundJobUpdate: backgroundJobUpdate || undefined,
547
548
  clientEnvironment: collectClientEnvironment({ env: session.env }),
548
549
  projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
550
+ sessionImageStore: describeSessionImageStore(session.env) ?? undefined,
549
551
  imageAttachments: imageAttachmentsForServer(requestImageAttachments),
550
552
  maxToolSteps: session.maxToolSteps,
551
553
  autoYes: session.autoYes,
@@ -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
  };
@@ -111,6 +111,53 @@ export function readSessionImageByIndex(index, env = process.env) {
111
111
  }
112
112
  return null;
113
113
  }
114
+ export function describeSessionImageStore(env = process.env) {
115
+ if (!activeSessionId)
116
+ return null;
117
+ const dir = getSessionImageDir(activeSessionId, env);
118
+ if (!existsSync(dir))
119
+ return null;
120
+ const indices = [];
121
+ try {
122
+ for (const name of readdirSync(dir)) {
123
+ const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
124
+ if (Number.isInteger(parsed) && parsed >= 1)
125
+ indices.push(parsed);
126
+ }
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ if (indices.length === 0)
132
+ return null;
133
+ return { dir, indices: indices.sort((a, b) => a - b) };
134
+ }
135
+ const MAX_STORE_DIR_CHARS = 512;
136
+ const MAX_STORE_INDICES = 200;
137
+ const STORE_CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
138
+ export function normalizeSessionImageStore(value) {
139
+ if (!value || typeof value !== 'object')
140
+ return null;
141
+ const record = value;
142
+ const dir = String(record.dir ?? '')
143
+ .replace(STORE_CONTROL_CHARS, ' ')
144
+ .trim()
145
+ .slice(0, MAX_STORE_DIR_CHARS);
146
+ if (!dir)
147
+ return null;
148
+ const seen = new Set();
149
+ for (const raw of Array.isArray(record.indices) ? record.indices : []) {
150
+ const parsed = Number(raw);
151
+ if (!Number.isInteger(parsed) || parsed < 1)
152
+ continue;
153
+ seen.add(parsed);
154
+ if (seen.size >= MAX_STORE_INDICES)
155
+ break;
156
+ }
157
+ if (seen.size === 0)
158
+ return null;
159
+ return { dir, indices: [...seen].sort((a, b) => a - b) };
160
+ }
114
161
  export class SessionImageError extends Error {
115
162
  code;
116
163
  constructor(message, code) {
@@ -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.',
@@ -148,6 +148,17 @@ export const SLASH_COMMANDS = [
148
148
  description: 'Quit the current session',
149
149
  },
150
150
  ];
151
+ export function composerAttachmentsAfterCancel(queuedAttachments, inFlightAttachments) {
152
+ const seen = new Set();
153
+ const kept = [];
154
+ for (const attachment of [...queuedAttachments, ...inFlightAttachments]) {
155
+ if (seen.has(attachment.index))
156
+ continue;
157
+ seen.add(attachment.index);
158
+ kept.push(attachment);
159
+ }
160
+ return kept;
161
+ }
151
162
  function getShellWidth(columns) {
152
163
  const safeColumns = Math.max(columns, 20);
153
164
  const targetWidth = Math.floor(safeColumns * TUI_WIDTH_RATIO);
@@ -912,6 +923,23 @@ function createSessionTranscript(session) {
912
923
  const transcript = buildTranscriptFromSessionHistory(session.history);
913
924
  return transcript.length ? transcript : createInitialTranscript();
914
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
+ }
915
943
  function createShellStore(initialState) {
916
944
  let state = initialState;
917
945
  let nextEntryId = 1;
@@ -1414,6 +1442,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1414
1442
  let latestUsageSummary = null;
1415
1443
  let pendingTurnEntries = [];
1416
1444
  let activeTurnAbort = null;
1445
+ let activeTurnImageAttachments = [];
1417
1446
  let activeServerTurnId = null;
1418
1447
  let unacknowledged = new Map();
1419
1448
  const blockedRows = new WeakMap();
@@ -1675,6 +1704,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1675
1704
  activeTurnAbort = null;
1676
1705
  cancelActiveCommand();
1677
1706
  const queued = store.getState().queuedMessage;
1707
+ const restoredAttachments = composerAttachmentsAfterCancel(queued ? queued.imageAttachments : [], activeTurnImageAttachments);
1708
+ activeTurnImageAttachments = [];
1678
1709
  const cancelledEntries = [
1679
1710
  ...takePendingTurnEntries(),
1680
1711
  {
@@ -1692,7 +1723,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1692
1723
  busySince: null,
1693
1724
  commandLog: [],
1694
1725
  cursor: queued ? queued.body.length : current.cursor,
1695
- imageAttachments: queued ? queued.imageAttachments : [],
1726
+ imageAttachments: restoredAttachments,
1696
1727
  input: queued ? queued.body : current.input,
1697
1728
  pastedChunks: queued ? queued.pastedChunks : current.pastedChunks,
1698
1729
  promptHistoryCursor: queued ? null : current.promptHistoryCursor,
@@ -1927,8 +1958,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1927
1958
  signal?.addEventListener('abort', onAbort, { once: true });
1928
1959
  store.update((current) => ({
1929
1960
  ...pauseBusyClock(current, Date.now()),
1961
+ ...closedJobsPickerFields(current),
1930
1962
  status: 'Waiting for your input',
1931
- userInputPrompt: createUserInputPromptState(questions, current.status),
1963
+ userInputPrompt: createUserInputPromptState(questions, statusBehindJobsPicker(current)),
1932
1964
  }));
1933
1965
  scheduleLiveFrameRemount();
1934
1966
  });
@@ -1961,6 +1993,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1961
1993
  resolvePermissionDecision = resolve;
1962
1994
  store.update((current) => ({
1963
1995
  ...pauseBusyClock(current, Date.now()),
1996
+ ...closedJobsPickerFields(current),
1964
1997
  approvalCursor: getDefaultApprovalCursor(request.options.length),
1965
1998
  approvalOpenedAt: Date.now(),
1966
1999
  approvalScrollOffset: 0,
@@ -1972,7 +2005,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1972
2005
  : undefined,
1973
2006
  filePath: request.filePath,
1974
2007
  options: request.options,
1975
- returnStatus: current.status,
2008
+ returnStatus: statusBehindJobsPicker(current),
1976
2009
  },
1977
2010
  status: request.title,
1978
2011
  }));
@@ -2034,17 +2067,19 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2034
2067
  });
2035
2068
  };
2036
2069
  const openJobsPicker = () => {
2070
+ if (store.getState().jobsPickerOpen)
2071
+ return;
2037
2072
  syncBackgroundJobsState();
2038
2073
  store.update((current) => ({
2039
2074
  ...current,
2040
2075
  commandCursor: 0,
2041
- cursor: 0,
2042
- input: '',
2043
2076
  jobsPickerExpandedId: null,
2044
2077
  jobsPickerIndex: 0,
2045
2078
  jobsPickerOpen: true,
2079
+ jobsPickerReturnStatus: current.status,
2046
2080
  status: 'Background jobs',
2047
2081
  }));
2082
+ scheduleLiveFrameRemount();
2048
2083
  };
2049
2084
  const appendJobOutputEntry = async (jobId) => {
2050
2085
  const id = jobId.trim();
@@ -2075,30 +2110,51 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2075
2110
  const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
2076
2111
  return current.backgroundJobs[index]?.id ?? null;
2077
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
+ };
2078
2124
  const handleJobsPickerOutput = async () => {
2079
2125
  const jobId = selectedJobsPickerId();
2080
2126
  if (!jobId)
2081
2127
  return;
2082
- await collectBackgroundJobUiOutputMutations({
2083
- session,
2084
- jobId,
2085
- });
2086
- syncBackgroundJobsState();
2128
+ if (store.getState().busy) {
2129
+ deferredJobMutationIds.add(jobId);
2130
+ }
2131
+ else {
2132
+ await collectBackgroundJobUiOutputMutations({
2133
+ session,
2134
+ jobId,
2135
+ });
2136
+ }
2087
2137
  store.update((current) => ({
2088
2138
  ...current,
2089
2139
  jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
2090
2140
  }));
2141
+ syncBackgroundJobsState();
2091
2142
  };
2092
2143
  const handleJobsPickerKill = async () => {
2093
2144
  const jobId = selectedJobsPickerId();
2094
2145
  if (!jobId)
2095
2146
  return;
2096
2147
  const killed = await killBackgroundJob(jobId);
2097
- await collectBackgroundJobUiKillMutations({
2098
- session,
2099
- jobId,
2100
- result: killed,
2101
- });
2148
+ if (store.getState().busy) {
2149
+ deferredJobMutationIds.add(jobId);
2150
+ }
2151
+ else {
2152
+ await collectBackgroundJobUiKillMutations({
2153
+ session,
2154
+ jobId,
2155
+ result: killed,
2156
+ });
2157
+ }
2102
2158
  syncBackgroundJobsState();
2103
2159
  if (!killed.ok) {
2104
2160
  appendError(killed.error ?? 'Background job kill failed.');
@@ -2390,8 +2446,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2390
2446
  if (!input || exiting)
2391
2447
  return;
2392
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
+ }
2393
2459
  appendTurnAwareEntry({
2394
- 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.",
2395
2461
  kind: 'system',
2396
2462
  title: 'Queued',
2397
2463
  });
@@ -2673,6 +2739,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2673
2739
  pendingTurnEntries = [];
2674
2740
  queueTurnEntry(userEntry);
2675
2741
  resetThinkingPacer();
2742
+ activeTurnImageAttachments = imageAttachments;
2676
2743
  store.update((current) => ({
2677
2744
  ...current,
2678
2745
  activeTurnInput: input,
@@ -2726,6 +2793,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2726
2793
  if (!(await saveActiveSession()))
2727
2794
  return;
2728
2795
  syncShellStateFromSession();
2796
+ activeTurnImageAttachments = [];
2729
2797
  store.update((current) => ({
2730
2798
  ...current,
2731
2799
  activeTurnInput: '',
@@ -2779,6 +2847,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2779
2847
  const cancelled = isTurnCancelledError(error);
2780
2848
  if (exitForAuthenticationError(error))
2781
2849
  return;
2850
+ const failedTurnAttachments = activeTurnImageAttachments;
2851
+ activeTurnImageAttachments = [];
2782
2852
  store.update((current) => ({
2783
2853
  ...current,
2784
2854
  activeTurnInput: '',
@@ -2787,7 +2857,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2787
2857
  busyPausedAt: null,
2788
2858
  busySince: null,
2789
2859
  commandLog: [],
2790
- imageAttachments: [],
2860
+ imageAttachments: failedTurnAttachments,
2791
2861
  status: cancelled ? 'Ready' : 'Turn failed',
2792
2862
  thinkingTitle: '',
2793
2863
  thinkingNotes: [],
@@ -2827,6 +2897,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2827
2897
  activeServerTurnId = null;
2828
2898
  }
2829
2899
  recoverUnacknowledgedMessages();
2900
+ await drainDeferredJobMutations();
2830
2901
  }
2831
2902
  };
2832
2903
  session.onImageAnalysis = (activeImageCount) => {
@@ -2879,26 +2950,20 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2879
2950
  }
2880
2951
  store.appendWorkingTool(buildWorkingToolEntry(event));
2881
2952
  };
2882
- const jobDetailLines = (jobId) => {
2883
- const buffered = getJobBufferedOutput(jobId);
2884
- if (!buffered)
2885
- return [];
2886
- const lines = buffered.output
2887
- .replace(/\r\n/g, '\n')
2888
- .replace(/\r/g, '\n')
2889
- .split('\n')
2890
- .filter((line) => line.trim().length > 0);
2891
- const visible = lines.slice(-20);
2892
- return buffered.droppedChars > 0
2893
- ? [
2894
- `... (${buffered.droppedChars} chars of older output dropped) ...`,
2895
- ...visible,
2896
- ]
2897
- : visible;
2898
- };
2899
2953
  const syncBackgroundJobsState = () => {
2954
+ const expandedId = store.getState().jobsPickerExpandedId;
2900
2955
  const jobsForDisplay = listBackgroundJobs().map((job) => {
2901
- 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 ?? []);
2902
2967
  return {
2903
2968
  id: job.id,
2904
2969
  command: job.command,
@@ -2907,8 +2972,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2907
2972
  startedAt: job.startedAt,
2908
2973
  endedAt: job.endedAt,
2909
2974
  firstOutputLine: preview?.firstLine ?? '',
2910
- tailLines: preview?.tailLines ?? [],
2911
- detailLines: jobDetailLines(job.id),
2975
+ tailLines: (preview?.tailLines ?? []).slice(-3),
2976
+ detailLines,
2912
2977
  };
2913
2978
  });
2914
2979
  store.update((current) => ({
@@ -2978,6 +3043,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2978
3043
  onResumeSession: handleInlineResumeSelection,
2979
3044
  onSelectionCopy: handleAppSelectionCopy,
2980
3045
  onSelectModel: handleInlineModelSelection,
3046
+ onOpenJobsPicker: openJobsPicker,
2981
3047
  onJobsPickerOutput: handleJobsPickerOutput,
2982
3048
  onJobsPickerKill: handleJobsPickerKill,
2983
3049
  onSudoPasswordInput: handleSudoPasswordInput,
@@ -41,6 +41,48 @@ const MODEL_PICKER_BORDER_COLOR = 'cyan';
41
41
  const MODEL_PICKER_ACCENT_COLOR = 'cyan';
42
42
  const MODEL_PICKER_HIGHLIGHT_BG = 'ansi256(87)';
43
43
  const MODEL_PICKER_META_INDENT = ' ';
44
+ const COMPOSER_MARKER_PATTERN = /\[Image #\d+\]|\[Pasted Text: [^\]\n]*\]/g;
45
+ function composerMarkerMask(text) {
46
+ const mask = new Array(text.length).fill(false);
47
+ for (const match of text.matchAll(COMPOSER_MARKER_PATTERN)) {
48
+ const start = match.index ?? 0;
49
+ for (let offset = start; offset < start + match[0].length; offset += 1) {
50
+ mask[offset] = true;
51
+ }
52
+ }
53
+ return mask;
54
+ }
55
+ function composerRowSpans(text, mask, cursorCol) {
56
+ const spans = [];
57
+ const push = (value, marker, cursor) => {
58
+ if (!value)
59
+ return;
60
+ spans.push(span(value, {
61
+ ...(marker ? { color: 'cyan' } : {}),
62
+ ...(cursor ? { inverse: true } : {}),
63
+ }));
64
+ };
65
+ let cut = 0;
66
+ const flushTo = (end) => {
67
+ while (cut < end) {
68
+ const marker = mask[cut] ?? false;
69
+ let runEnd = cut + 1;
70
+ while (runEnd < end && (mask[runEnd] ?? false) === marker)
71
+ runEnd += 1;
72
+ push(text.slice(cut, runEnd), marker, false);
73
+ cut = runEnd;
74
+ }
75
+ };
76
+ if (cursorCol === null) {
77
+ flushTo(text.length);
78
+ return spans;
79
+ }
80
+ flushTo(Math.min(cursorCol, text.length));
81
+ push(text[cursorCol] ?? ' ', mask[cursorCol] ?? false, true);
82
+ cut = Math.min(cursorCol + 1, text.length);
83
+ flushTo(text.length);
84
+ return spans;
85
+ }
44
86
  function buildComposerInputLines(input, cursor, promptLabel, placeholder, width) {
45
87
  if (!input) {
46
88
  return [
@@ -51,20 +93,22 @@ function buildComposerInputLines(input, cursor, promptLabel, placeholder, width)
51
93
  const inputWidth = Math.max(1, width - labelWidth);
52
94
  const { cursorCol, cursorRow, rows } = splitComposerInput(input, cursor, inputWidth);
53
95
  const firstRow = Math.min(Math.max(cursorRow - COMPOSER_INPUT_MAX_ROWS + 1, 0), Math.max(rows.length - COMPOSER_INPUT_MAX_ROWS, 0));
96
+ const rowOffsets = [];
97
+ let consumed = 0;
98
+ for (const row of rows) {
99
+ rowOffsets.push(consumed);
100
+ consumed += row.length;
101
+ }
102
+ const mask = composerMarkerMask(rows.join(''));
54
103
  return rows
55
104
  .slice(firstRow, firstRow + COMPOSER_INPUT_MAX_ROWS)
56
105
  .map((text, index) => {
57
106
  const absoluteRow = firstRow + index;
107
+ const rowStart = rowOffsets[absoluteRow] ?? 0;
58
108
  const label = index === 0
59
109
  ? span(promptLabel, { color: 'cyan' })
60
110
  : span(' '.repeat(labelWidth));
61
- if (absoluteRow !== cursorRow) {
62
- return line(label, span(text));
63
- }
64
- const before = text.slice(0, cursorCol);
65
- const cursorChar = text[cursorCol] ?? ' ';
66
- const after = text.slice(cursorCol + 1);
67
- return line(label, span(before), span(cursorChar, { inverse: true }), span(after));
111
+ return line(label, ...composerRowSpans(text, mask.slice(rowStart, rowStart + text.length), absoluteRow === cursorRow ? cursorCol : null));
68
112
  });
69
113
  }
70
114
  function getEntryColor(kind) {
@@ -410,10 +454,11 @@ function backgroundJobIndicatorSpans(state) {
410
454
  if (runningCount === 0)
411
455
  return [];
412
456
  const noun = runningCount === 1 ? 'shell' : 'shells';
457
+ const hint = state.busy ? ' · ctrl+b' : ' · /jobs';
413
458
  return [
414
459
  span(' ', { color: 'gray', dim: true }),
415
460
  span(`● ${runningCount} ${noun} running`, { color: 'green', bold: true }),
416
- span(' · /jobs', { color: 'gray', dim: true }),
461
+ span(hint, { color: 'gray', dim: true }),
417
462
  ];
418
463
  }
419
464
  function todoIndicatorSpans(state) {
@@ -535,15 +580,29 @@ function jobStatusDescriptor(job, nowMs) {
535
580
  ? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
536
581
  : { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
537
582
  }
538
- 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) {
539
586
  const jobs = state.backgroundJobs ?? [];
587
+ const bodyBudget = Math.max(1, height - JOBS_PICKER_CHROME_ROWS);
540
588
  const lines = [
541
589
  plainLine('Background jobs', { color: 'cyan', bold: true }),
542
590
  ];
543
591
  if (jobs.length === 0) {
544
592
  lines.push(plainLine('No background jobs in this session.', { color: 'gray' }));
545
593
  }
546
- 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;
547
606
  const selected = index === state.jobsPickerIndex;
548
607
  const expanded = state.jobsPickerExpandedId === job.id;
549
608
  const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
@@ -552,10 +611,11 @@ function buildJobsPickerLines(state, width, nowMs) {
552
611
  bold: selected,
553
612
  }), span(` ${text}`, { color: 'gray' })));
554
613
  if (expanded) {
555
- const detailLines = (job.detailLines ?? []).length
614
+ const allDetail = (job.detailLines ?? []).length
556
615
  ? job.detailLines ?? []
557
616
  : backgroundJobPreviewLines(job, 3);
558
- if (detailLines.length === 0) {
617
+ const detailLines = allDetail.slice(-detailBudget);
618
+ if (detailBudget > 0 && detailLines.length === 0) {
559
619
  lines.push(plainLine(' (no output captured)', { color: 'gray' }));
560
620
  }
561
621
  else {
@@ -568,6 +628,10 @@ function buildJobsPickerLines(state, width, nowMs) {
568
628
  }
569
629
  }
570
630
  }
631
+ const remaining = jobs.length - (start + visible.length);
632
+ if (remaining > 0) {
633
+ lines.push(plainLine(` … ${remaining} older`, { color: 'gray', dim: true }));
634
+ }
571
635
  lines.push(plainLine('↑/↓ move enter expand/collapse k kill esc close', {
572
636
  color: 'gray',
573
637
  }));
@@ -1075,7 +1139,7 @@ function buildOverlayLines(state, width, height, nowMs) {
1075
1139
  lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
1076
1140
  }
1077
1141
  if (state.jobsPickerOpen) {
1078
- lines.push(...buildJobsPickerLines(state, width, nowMs));
1142
+ lines.push(...buildJobsPickerLines(state, width, height, nowMs));
1079
1143
  }
1080
1144
  if (state.resumePickerOpen) {
1081
1145
  const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
@@ -1215,7 +1279,9 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1215
1279
  }
1216
1280
  const overlayHeight = state.userInputPrompt
1217
1281
  ? Math.max(0, rows - countSectionLines(sections))
1218
- : rows;
1282
+ : state.jobsPickerOpen
1283
+ ? Math.max(Math.min(rows, JOBS_PICKER_MIN_ROWS), rows - countSectionLines(sections))
1284
+ : rows;
1219
1285
  const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
1220
1286
  if (overlayLines.length > 0) {
1221
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.34",
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.34",
48
- "@thegitai/tui-darwin-x64": "1.0.0-preview.34",
49
- "@thegitai/tui-linux-arm64": "1.0.0-preview.34",
50
- "@thegitai/tui-linux-x64": "1.0.0-preview.34",
51
- "@thegitai/tui-win32-x64": "1.0.0-preview.34",
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": {