@thegitai/cli 1.0.0-beta.15 → 1.0.0-beta.17

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.
@@ -0,0 +1,48 @@
1
+ import { killBackgroundJob } from '../background-jobs.js';
2
+ import { redactConnectionStringCredentials } from '../secret-preview.js';
3
+ const MAX_JOB_TOOL_OUTPUT_CHARS = 64 * 1024;
4
+ function boundJobToolOutput(output) {
5
+ if (output.length <= MAX_JOB_TOOL_OUTPUT_CHARS)
6
+ return output;
7
+ const headSize = Math.floor(MAX_JOB_TOOL_OUTPUT_CHARS * 0.2);
8
+ const tailSize = MAX_JOB_TOOL_OUTPUT_CHARS - headSize;
9
+ return (output.slice(0, headSize) +
10
+ `\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
11
+ output.slice(-tailSize));
12
+ }
13
+ export async function shellJobKill(context, args) {
14
+ const jobId = String(args.job_id ?? '').trim();
15
+ if (!jobId) {
16
+ return {
17
+ ok: false,
18
+ error: 'job_id is required',
19
+ failureCategory: 'missing_required_argument',
20
+ };
21
+ }
22
+ const result = await killBackgroundJob(jobId, {
23
+ sessionId: context.sessionId,
24
+ });
25
+ if (!result.ok || !result.snapshot) {
26
+ return {
27
+ ok: false,
28
+ error: result.error ?? 'Background job lookup failed.',
29
+ failureCategory: 'not_found',
30
+ };
31
+ }
32
+ const snapshot = result.snapshot;
33
+ let finalOutput = redactConnectionStringCredentials(result.finalOutput ?? '');
34
+ if (result.droppedChars) {
35
+ finalOutput = `... (${result.droppedChars} chars of older output dropped) ...\n${finalOutput}`;
36
+ }
37
+ finalOutput = boundJobToolOutput(finalOutput);
38
+ return {
39
+ ok: true,
40
+ jobId: snapshot.id,
41
+ command: snapshot.command,
42
+ status: snapshot.status,
43
+ exitCode: snapshot.exitCode,
44
+ alreadyFinished: result.alreadyFinished === true,
45
+ ranMs: (snapshot.endedAt ?? Date.now()) - snapshot.startedAt,
46
+ finalOutput,
47
+ };
48
+ }
@@ -0,0 +1,51 @@
1
+ import { MAX_JOB_WAIT_MS, readBackgroundJobOutput, } from '../background-jobs.js';
2
+ import { redactConnectionStringCredentials } from '../secret-preview.js';
3
+ const MAX_JOB_TOOL_OUTPUT_CHARS = 64 * 1024;
4
+ function boundJobToolOutput(output) {
5
+ if (output.length <= MAX_JOB_TOOL_OUTPUT_CHARS)
6
+ return output;
7
+ const headSize = Math.floor(MAX_JOB_TOOL_OUTPUT_CHARS * 0.2);
8
+ const tailSize = MAX_JOB_TOOL_OUTPUT_CHARS - headSize;
9
+ return (output.slice(0, headSize) +
10
+ `\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
11
+ output.slice(-tailSize));
12
+ }
13
+ export async function shellJobOutput(context, args) {
14
+ const jobId = String(args.job_id ?? '').trim();
15
+ if (!jobId) {
16
+ return {
17
+ ok: false,
18
+ error: 'job_id is required',
19
+ failureCategory: 'missing_required_argument',
20
+ };
21
+ }
22
+ const waitMs = typeof args.wait_ms === 'number' && args.wait_ms > 0
23
+ ? Math.min(args.wait_ms, MAX_JOB_WAIT_MS)
24
+ : 0;
25
+ const result = await readBackgroundJobOutput(jobId, {
26
+ waitMs,
27
+ sessionId: context.sessionId,
28
+ });
29
+ if (!result.ok || !result.snapshot) {
30
+ return {
31
+ ok: false,
32
+ error: result.error ?? 'Background job lookup failed.',
33
+ failureCategory: 'not_found',
34
+ };
35
+ }
36
+ const snapshot = result.snapshot;
37
+ let newOutput = redactConnectionStringCredentials(result.newOutput ?? '');
38
+ if (result.droppedChars) {
39
+ newOutput = `... (${result.droppedChars} chars of older output dropped) ...\n${newOutput}`;
40
+ }
41
+ newOutput = boundJobToolOutput(newOutput);
42
+ return {
43
+ ok: true,
44
+ jobId: snapshot.id,
45
+ command: snapshot.command,
46
+ status: snapshot.status,
47
+ exitCode: snapshot.exitCode,
48
+ elapsedMs: (snapshot.endedAt ?? Date.now()) - snapshot.startedAt,
49
+ newOutput,
50
+ };
51
+ }
@@ -1,11 +1,13 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
- import { buildTuiFrame, renderTranscriptEntryLines } from './tui/build-frame.js';
2
+ import { buildTuiFrame, formatJobElapsed, 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';
7
8
  import { cancelActiveCommand } from '../executor.js';
8
9
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
10
+ import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
9
11
  import { clearConversation, } from '../session.js';
10
12
  import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
11
13
  import { truncate } from '../utils.js';
@@ -102,6 +104,10 @@ export const SLASH_COMMANDS = [
102
104
  command: '/resume',
103
105
  description: 'Open the session picker',
104
106
  },
107
+ {
108
+ command: '/jobs',
109
+ description: 'Background jobs: pick to view output or kill',
110
+ },
105
111
  {
106
112
  command: '/clear',
107
113
  description: 'Clear conversation history',
@@ -643,12 +649,33 @@ function buildWorkingToolEntry(event) {
643
649
  : '';
644
650
  if (call.name === 'run_command') {
645
651
  const command = String(call.args?.command ?? result?.command ?? '').trim();
652
+ if (result?.backgrounded === true && result?.jobId) {
653
+ return {
654
+ body: `$ ${truncate(command, 180)}\nBackground job ${String(result.jobId)} started.${error}`,
655
+ kind: result?.ok === true ? 'tool' : 'error',
656
+ preformatted: true,
657
+ title: 'Shell',
658
+ };
659
+ }
646
660
  return {
647
661
  body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
648
662
  kind: result?.ok === true ? 'tool' : 'error',
649
663
  title: 'Shell',
650
664
  };
651
665
  }
666
+ if (call.name === 'shell_job_output' || call.name === 'shell_job_kill') {
667
+ const jobId = String(result?.jobId ?? call.args?.job_id ?? '').trim();
668
+ const status = String(result?.status ?? '').trim();
669
+ const exitCode = result?.exitCode;
670
+ const stateText = status
671
+ ? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
672
+ : formatToolResultState(result);
673
+ return {
674
+ body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
675
+ kind: result?.ok === true ? 'tool' : 'error',
676
+ title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
677
+ };
678
+ }
652
679
  if (call.name === 'run_node_script') {
653
680
  return {
654
681
  body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
@@ -666,6 +693,32 @@ function buildWorkingToolEntry(event) {
666
693
  title: 'Tool',
667
694
  };
668
695
  }
696
+ function buildBackgroundJobNoticeEntry(snapshot) {
697
+ const command = truncate(snapshot.command, 120);
698
+ const ran = formatJobElapsed((snapshot.endedAt ?? Date.now()) - snapshot.startedAt);
699
+ if (snapshot.status === 'error') {
700
+ return {
701
+ body: `✖ ${snapshot.id} (${command}) failed to start`,
702
+ kind: 'error',
703
+ preformatted: true,
704
+ title: 'Background job',
705
+ };
706
+ }
707
+ if (snapshot.status === 'killed') {
708
+ return {
709
+ body: `■ ${snapshot.id} (${command}) killed · ran ${ran}`,
710
+ kind: 'system',
711
+ preformatted: true,
712
+ title: 'Background job',
713
+ };
714
+ }
715
+ return {
716
+ body: `${snapshot.exitCode === 0 ? '✓' : '✖'} ${snapshot.id} (${command}) exited (code ${snapshot.exitCode ?? 1}) · ran ${ran}`,
717
+ kind: snapshot.exitCode === 0 ? 'system' : 'error',
718
+ preformatted: true,
719
+ title: 'Background job',
720
+ };
721
+ }
669
722
  function findPendingToolCallByName(pendingCalls, name) {
670
723
  for (const [id, call] of pendingCalls) {
671
724
  if (call.name !== name)
@@ -868,6 +921,7 @@ function createInitialShellState(session, serverModels, debugUi) {
868
921
  approvalCursor: getDefaultApprovalCursor(),
869
922
  approvalPrompt: null,
870
923
  autoYes: session.autoYes,
924
+ backgroundJobs: [],
871
925
  busy: false,
872
926
  busySince: null,
873
927
  clockNow: Date.now(),
@@ -879,6 +933,9 @@ function createInitialShellState(session, serverModels, debugUi) {
879
933
  exiting: false,
880
934
  input: '',
881
935
  maxToolSteps: session.maxToolSteps,
936
+ jobsPickerExpandedId: null,
937
+ jobsPickerIndex: 0,
938
+ jobsPickerOpen: false,
882
939
  modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
883
940
  modelPickerOpen: false,
884
941
  projectRoot: session.rootDir,
@@ -1183,6 +1240,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1183
1240
  throw new Error('Client TUI requires an interactive terminal.');
1184
1241
  }
1185
1242
  await withTuiMode(async () => {
1243
+ setBackgroundJobSession(session.sessionId);
1186
1244
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1187
1245
  store.replaceTranscript(createSessionTranscript(session));
1188
1246
  let currentServerModels = serverModels;
@@ -1240,7 +1298,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1240
1298
  const elapsedSeconds = state.busySince
1241
1299
  ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1242
1300
  : 0;
1243
- bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds));
1301
+ bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1244
1302
  };
1245
1303
  const remountTui = async () => {
1246
1304
  if (remountPromise) {
@@ -1522,6 +1580,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1522
1580
  dismissPendingApproval();
1523
1581
  dismissPendingSudoPassword();
1524
1582
  exiting = true;
1583
+ killAllBackgroundJobs();
1525
1584
  store.update((current) => ({
1526
1585
  ...current,
1527
1586
  exiting: true,
@@ -1676,6 +1735,87 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1676
1735
  title: 'Model',
1677
1736
  });
1678
1737
  };
1738
+ const openJobsPicker = () => {
1739
+ syncBackgroundJobsState();
1740
+ store.update((current) => ({
1741
+ ...current,
1742
+ commandCursor: 0,
1743
+ cursor: 0,
1744
+ input: '',
1745
+ jobsPickerExpandedId: null,
1746
+ jobsPickerIndex: 0,
1747
+ jobsPickerOpen: true,
1748
+ status: 'Background jobs',
1749
+ }));
1750
+ };
1751
+ const appendJobOutputEntry = async (jobId) => {
1752
+ const id = jobId.trim();
1753
+ await collectBackgroundJobUiOutputMutations({
1754
+ session,
1755
+ projectIndex,
1756
+ jobId: id,
1757
+ });
1758
+ const job = listBackgroundJobs().find((candidate) => candidate.id === id);
1759
+ const buffered = getJobBufferedOutput(id);
1760
+ if (!job || !buffered) {
1761
+ appendError(`Unknown background job id: ${id}`);
1762
+ return;
1763
+ }
1764
+ const dropped = buffered.droppedChars > 0
1765
+ ? `... (${buffered.droppedChars} chars of older output dropped) ...\n`
1766
+ : '';
1767
+ appendStaticEntry({
1768
+ body: `${dropped}${buffered.output || '(no output captured)'}`,
1769
+ kind: 'system',
1770
+ preformatted: true,
1771
+ title: `${job.id} output — ${truncate(job.command, 80)}`,
1772
+ });
1773
+ };
1774
+ const selectedJobsPickerId = () => {
1775
+ const current = store.getState();
1776
+ if (!current.backgroundJobs.length)
1777
+ return null;
1778
+ const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
1779
+ return current.backgroundJobs[index]?.id ?? null;
1780
+ };
1781
+ const handleJobsPickerOutput = async () => {
1782
+ const jobId = selectedJobsPickerId();
1783
+ if (!jobId)
1784
+ return;
1785
+ await collectBackgroundJobUiOutputMutations({
1786
+ session,
1787
+ projectIndex,
1788
+ jobId,
1789
+ });
1790
+ syncBackgroundJobsState();
1791
+ store.update((current) => ({
1792
+ ...current,
1793
+ jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
1794
+ }));
1795
+ };
1796
+ const handleJobsPickerKill = async () => {
1797
+ const jobId = selectedJobsPickerId();
1798
+ if (!jobId)
1799
+ return;
1800
+ const killed = await killBackgroundJob(jobId);
1801
+ await collectBackgroundJobUiKillMutations({
1802
+ session,
1803
+ projectIndex,
1804
+ jobId,
1805
+ result: killed,
1806
+ });
1807
+ syncBackgroundJobsState();
1808
+ if (!killed.ok) {
1809
+ appendError(killed.error ?? 'Background job kill failed.');
1810
+ }
1811
+ else if (killed.alreadyFinished) {
1812
+ appendStaticEntry({
1813
+ body: `${jobId} had already finished.`,
1814
+ kind: 'system',
1815
+ title: 'Background jobs',
1816
+ });
1817
+ }
1818
+ };
1679
1819
  const handleInlineModelSelection = async () => {
1680
1820
  const current = store.getState();
1681
1821
  const options = buildModelPickerOptions(current.currentModelId, current.serverModels);
@@ -1762,6 +1902,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1762
1902
  try {
1763
1903
  const snapshot = await loadInteractiveSession(selected.id);
1764
1904
  applySessionSnapshot(session, snapshot);
1905
+ setBackgroundJobSession(session.sessionId);
1906
+ syncBackgroundJobsState();
1765
1907
  await saveActiveSession();
1766
1908
  syncShellStateFromSession();
1767
1909
  store.update((next) => ({
@@ -1883,6 +2025,46 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1883
2025
  });
1884
2026
  return;
1885
2027
  }
2028
+ if (input === '/jobs' || input.startsWith('/jobs ')) {
2029
+ const jobsArgs = input.slice('/jobs'.length).trim();
2030
+ if (!jobsArgs) {
2031
+ openJobsPicker();
2032
+ return;
2033
+ }
2034
+ const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
2035
+ if (killMatch) {
2036
+ const jobId = killMatch[1];
2037
+ const killed = await killBackgroundJob(jobId);
2038
+ await collectBackgroundJobUiKillMutations({
2039
+ session,
2040
+ projectIndex,
2041
+ jobId,
2042
+ result: killed,
2043
+ });
2044
+ if (!killed.ok) {
2045
+ appendError(killed.error ?? 'Background job kill failed.');
2046
+ }
2047
+ else if (killed.alreadyFinished) {
2048
+ appendStaticEntry({
2049
+ body: `${jobId} had already finished.`,
2050
+ kind: 'system',
2051
+ title: 'Background jobs',
2052
+ });
2053
+ }
2054
+ return;
2055
+ }
2056
+ const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
2057
+ if (outputMatch) {
2058
+ await appendJobOutputEntry(outputMatch[1]);
2059
+ return;
2060
+ }
2061
+ appendStaticEntry({
2062
+ body: 'Usage: /jobs — open the jobs picker · /jobs output <id> — full output · /jobs kill <id> — kill one',
2063
+ kind: 'system',
2064
+ title: 'Background jobs',
2065
+ });
2066
+ return;
2067
+ }
1886
2068
  if (input === '/usage') {
1887
2069
  store.update((current) => ({
1888
2070
  ...current,
@@ -2121,6 +2303,52 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2121
2303
  }
2122
2304
  store.appendWorkingTool(buildWorkingToolEntry(event));
2123
2305
  };
2306
+ const jobDetailLines = (jobId) => {
2307
+ const buffered = getJobBufferedOutput(jobId);
2308
+ if (!buffered)
2309
+ return [];
2310
+ const lines = buffered.output
2311
+ .replace(/\r\n/g, '\n')
2312
+ .replace(/\r/g, '\n')
2313
+ .split('\n')
2314
+ .filter((line) => line.trim().length > 0);
2315
+ const visible = lines.slice(-20);
2316
+ return buffered.droppedChars > 0
2317
+ ? [
2318
+ `... (${buffered.droppedChars} chars of older output dropped) ...`,
2319
+ ...visible,
2320
+ ]
2321
+ : visible;
2322
+ };
2323
+ const syncBackgroundJobsState = () => {
2324
+ const jobsForDisplay = listBackgroundJobs().map((job) => {
2325
+ const preview = getJobOutputPreview(job.id, 3);
2326
+ return {
2327
+ id: job.id,
2328
+ command: job.command,
2329
+ status: job.status,
2330
+ exitCode: job.exitCode,
2331
+ startedAt: job.startedAt,
2332
+ endedAt: job.endedAt,
2333
+ firstOutputLine: preview?.firstLine ?? '',
2334
+ tailLines: preview?.tailLines ?? [],
2335
+ detailLines: jobDetailLines(job.id),
2336
+ };
2337
+ });
2338
+ store.update((current) => ({
2339
+ ...current,
2340
+ backgroundJobs: jobsForDisplay,
2341
+ jobsPickerExpandedId: jobsForDisplay.some((job) => job.id === current.jobsPickerExpandedId)
2342
+ ? current.jobsPickerExpandedId
2343
+ : null,
2344
+ }));
2345
+ };
2346
+ setBackgroundJobUpdateHook((snapshot) => {
2347
+ syncBackgroundJobsState();
2348
+ if (snapshot.status !== 'running') {
2349
+ appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2350
+ }
2351
+ });
2124
2352
  projectIndex.onStatus = session.onStatus;
2125
2353
  projectIndex.onContextLog = session.onContextLog;
2126
2354
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
@@ -2180,6 +2408,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2180
2408
  onResumeSession: handleInlineResumeSelection,
2181
2409
  onSelectionCopy: handleAppSelectionCopy,
2182
2410
  onSelectModel: handleInlineModelSelection,
2411
+ onJobsPickerOutput: handleJobsPickerOutput,
2412
+ onJobsPickerKill: handleJobsPickerKill,
2183
2413
  onSudoPasswordInput: handleSudoPasswordInput,
2184
2414
  onSubmit: handleSubmit,
2185
2415
  };
@@ -2193,7 +2423,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2193
2423
  }
2194
2424
  }, 120);
2195
2425
  const clockTimer = setInterval(() => {
2196
- if (store.getState().busy) {
2426
+ if (hasRunningBackgroundJobs()) {
2427
+ syncBackgroundJobsState();
2428
+ }
2429
+ if (store.getState().busy || hasRunningBackgroundJobs()) {
2197
2430
  renderCurrentFrame();
2198
2431
  }
2199
2432
  }, 1000);
@@ -2237,6 +2470,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2237
2470
  clearInterval(clockTimer);
2238
2471
  unsubscribe();
2239
2472
  clearLiveFrameRemountTimer();
2473
+ killAllBackgroundJobs();
2474
+ setBackgroundJobSession(null);
2475
+ setBackgroundJobUpdateHook(null);
2240
2476
  await bridge.close();
2241
2477
  setCommandOutputHook(null);
2242
2478
  }
@@ -160,6 +160,7 @@ const CLIENT_SLASH_COMMANDS = [
160
160
  { command: '/usage', description: 'Show account usage percentage and reset times' },
161
161
  { command: '/model', description: 'Switch the active model' },
162
162
  { command: '/resume', description: 'Open the session picker to resume a previous session' },
163
+ { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
163
164
  { command: '/clear', description: 'Clear conversation history' },
164
165
  { command: '/exit', description: 'Quit the current session' },
165
166
  ];
@@ -341,6 +342,83 @@ function composerFooterLines(state) {
341
342
  lines.push(line(...footerSpans));
342
343
  return lines;
343
344
  }
345
+ export function formatJobElapsed(elapsedMs) {
346
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
347
+ if (totalSeconds < 60)
348
+ return `${totalSeconds}s`;
349
+ const minutes = Math.floor(totalSeconds / 60);
350
+ if (minutes < 60) {
351
+ return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
352
+ }
353
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
354
+ }
355
+ function buildBackgroundJobsLine(state, width, nowMs) {
356
+ const running = (state.backgroundJobs ?? []).filter((job) => job.status === 'running');
357
+ if (running.length === 0)
358
+ return null;
359
+ const countLabel = `${running.length} job${running.length === 1 ? '' : 's'}`;
360
+ const parts = running.map((job) => {
361
+ const elapsed = nowMs > 0 ? ` · ${formatJobElapsed(nowMs - job.startedAt)}` : '';
362
+ return `${truncate(job.command, 48)}${elapsed}`;
363
+ });
364
+ const text = truncate(`${countLabel} · ${parts.join(' · ')}`, Math.max(8, width - 2));
365
+ return line(span('● ', { color: 'green' }), span(text, { color: 'gray', dim: true }));
366
+ }
367
+ const JOB_LIVE_TAIL_LINES = 8;
368
+ const JOB_FINISHED_TAIL_LINES = 3;
369
+ function jobStatusDescriptor(job, nowMs) {
370
+ const ran = formatJobElapsed((job.endedAt ?? (nowMs > 0 ? nowMs : Date.now())) - job.startedAt);
371
+ if (job.status === 'running') {
372
+ return { glyph: '●', color: 'green', text: `running · ${ran}` };
373
+ }
374
+ if (job.status === 'killed') {
375
+ return { glyph: '■', color: 'gray', text: `killed · ran ${ran}` };
376
+ }
377
+ if (job.status === 'error') {
378
+ return { glyph: '✖', color: 'red', text: 'failed to start' };
379
+ }
380
+ return job.exitCode === 0
381
+ ? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
382
+ : { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
383
+ }
384
+ function renderBackgroundJobEntryLines(entry, state, width, nowMs) {
385
+ const job = (state.backgroundJobs ?? []).find((candidate) => candidate.id === entry.jobId);
386
+ if (!job) {
387
+ return renderTranscriptEntryLines({ ...entry, jobId: undefined }, width);
388
+ }
389
+ const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
390
+ const header = line(span(`${glyph} `, { color }), span(`${job.id} · ${truncate(job.command, Math.max(12, width - 24))}`, {
391
+ color,
392
+ bold: true,
393
+ }), span(` · ${text}`, { color: 'gray' }));
394
+ const tailBudget = job.status === 'running' ? JOB_LIVE_TAIL_LINES : JOB_FINISHED_TAIL_LINES;
395
+ const tail = (job.tailLines ?? []).slice(-tailBudget).map((tailLine) => line(span('│ ', { color: 'gray', dim: true }), span(truncate(tailLine, Math.max(8, width - 4)), {
396
+ color: 'gray',
397
+ dim: true,
398
+ })));
399
+ return [header, ...tail];
400
+ }
401
+ function buildJobsPickerLines(state, width, nowMs) {
402
+ const jobs = state.backgroundJobs ?? [];
403
+ const lines = [
404
+ plainLine('Background jobs', { color: 'cyan', bold: true }),
405
+ ];
406
+ if (jobs.length === 0) {
407
+ lines.push(plainLine('No background jobs in this session.', { color: 'gray' }));
408
+ }
409
+ for (const [index, job] of jobs.entries()) {
410
+ const selected = index === state.jobsPickerIndex;
411
+ const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
412
+ lines.push(line(span(selected ? '› ' : ' ', { color: selected ? 'cyan' : 'gray' }), span(`${glyph} `, { color }), span(job.id.padEnd(6), { color: selected ? 'cyan' : undefined, bold: selected }), span(` ${truncate(job.command, Math.max(12, width - 34)).padEnd(Math.max(12, Math.min(40, width - 34)))}`, {
413
+ color: selected ? 'cyan' : undefined,
414
+ bold: selected,
415
+ }), span(` ${text}`, { color: 'gray' })));
416
+ }
417
+ lines.push(plainLine('↑/↓ move enter output k kill esc close', {
418
+ color: 'gray',
419
+ }));
420
+ return lines;
421
+ }
344
422
  function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
345
423
  if (!state.busy)
346
424
  return [];
@@ -551,7 +629,7 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
551
629
  body.push(modelPickerPanelSideLine(plainLine(''), innerWidth), modelPickerPanelSideLine(line(span('─'.repeat(innerWidth), { color: MODEL_PICKER_BORDER_COLOR })), innerWidth), modelPickerPanelSideLine(plainLine('↑/↓ choose • Tab or Enter accept • Esc cancel', { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
552
630
  return [...margin, ...body, ...margin];
553
631
  }
554
- function buildOverlayLines(state, width) {
632
+ function buildOverlayLines(state, width, nowMs) {
555
633
  const lines = [];
556
634
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
557
635
  const innerWidth = Math.max(1, panelWidth - 4);
@@ -626,6 +704,9 @@ function buildOverlayLines(state, width) {
626
704
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
627
705
  lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width));
628
706
  }
707
+ if (state.jobsPickerOpen) {
708
+ lines.push(...buildJobsPickerLines(state, width, nowMs));
709
+ }
629
710
  if (state.resumePickerOpen) {
630
711
  const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
631
712
  lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
@@ -667,10 +748,12 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
667
748
  const start = Math.max(0, lines.length - maxLines - offset);
668
749
  return lines.slice(start, start + maxLines);
669
750
  }
670
- export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
751
+ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
671
752
  const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
672
753
  const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
673
- const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
754
+ const transcriptBlocks = state.transcript.map((entry) => entry.jobId
755
+ ? renderBackgroundJobEntryLines(entry, state, contentWidth, nowMs)
756
+ : renderTranscriptEntryLines(entry, contentWidth));
674
757
  const transcriptLines = [];
675
758
  transcriptBlocks.forEach((block, index) => {
676
759
  if (index > 0) {
@@ -684,7 +767,11 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
684
767
  sections.push({ kind: 'live', lines: liveLines });
685
768
  }
686
769
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
687
- if (!state.resumePickerOpen && !state.modelPickerOpen && !overlayActive) {
770
+ if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
771
+ const jobsLine = buildBackgroundJobsLine(state, contentWidth, nowMs);
772
+ if (jobsLine) {
773
+ sections.push({ kind: 'busyFooter', lines: [jobsLine, plainLine('')] });
774
+ }
688
775
  const composerLines = [];
689
776
  if (state.queuedMessage) {
690
777
  const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
@@ -705,7 +792,7 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
705
792
  lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
706
793
  });
707
794
  }
708
- const overlayLines = buildOverlayLines(state, contentWidth);
795
+ const overlayLines = buildOverlayLines(state, contentWidth, nowMs);
709
796
  if (overlayLines.length > 0) {
710
797
  sections.push({ kind: 'overlay', lines: overlayLines });
711
798
  }
@@ -12,6 +12,7 @@ function isClipboardImagePasteKey(key) {
12
12
  function shouldShowCommandPalette(state) {
13
13
  if (state.busy ||
14
14
  state.exiting ||
15
+ state.jobsPickerOpen ||
15
16
  state.modelPickerOpen ||
16
17
  state.resumePickerOpen) {
17
18
  return false;
@@ -57,6 +58,7 @@ function pasteTextFromClipboard(store, handlers) {
57
58
  const current = store.getState();
58
59
  if (current.exiting ||
59
60
  current.approvalPrompt ||
61
+ current.jobsPickerOpen ||
60
62
  current.modelPickerOpen ||
61
63
  current.resumePickerOpen ||
62
64
  current.sudoPrompt) {
@@ -210,6 +212,35 @@ export function handleShellKeyEvent(store, handlers, event) {
210
212
  });
211
213
  return;
212
214
  }
215
+ if (state.jobsPickerOpen) {
216
+ if (key.escape) {
217
+ store.update((current) => ({
218
+ ...current,
219
+ jobsPickerOpen: false,
220
+ status: 'Ready',
221
+ }));
222
+ return;
223
+ }
224
+ if (key.upArrow || key.downArrow) {
225
+ store.update((current) => {
226
+ const count = (current.backgroundJobs ?? []).length;
227
+ const next = current.jobsPickerIndex + (key.upArrow ? -1 : 1);
228
+ return {
229
+ ...current,
230
+ jobsPickerIndex: Math.max(0, Math.min(next, Math.max(count - 1, 0))),
231
+ };
232
+ });
233
+ return;
234
+ }
235
+ if (key.returnKey) {
236
+ void handlers.onJobsPickerOutput();
237
+ return;
238
+ }
239
+ if (key.input === 'k' && !key.ctrl && !key.meta && !key.shift) {
240
+ void handlers.onJobsPickerKill();
241
+ }
242
+ return;
243
+ }
213
244
  if (state.modelPickerOpen) {
214
245
  if (key.escape) {
215
246
  store.update((current) => ({ ...current, modelPickerOpen: false }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-beta.15",
3
+ "version": "1.0.0-beta.17",
4
4
  "description": "TheGitAI CLI client (source-visible, proprietary)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://thegit.ai",
@@ -25,10 +25,10 @@
25
25
  "@lydell/node-pty-linux-x64": "1.1.0",
26
26
  "@lydell/node-pty-win32-arm64": "1.1.0",
27
27
  "@lydell/node-pty-win32-x64": "1.1.0",
28
- "@thegitai/tui-darwin-arm64": "1.0.0-beta.15",
29
- "@thegitai/tui-darwin-x64": "1.0.0-beta.15",
30
- "@thegitai/tui-linux-x64": "1.0.0-beta.15",
31
- "@thegitai/tui-win32-x64": "1.0.0-beta.15",
28
+ "@thegitai/tui-darwin-arm64": "1.0.0-beta.17",
29
+ "@thegitai/tui-darwin-x64": "1.0.0-beta.17",
30
+ "@thegitai/tui-linux-x64": "1.0.0-beta.17",
31
+ "@thegitai/tui-win32-x64": "1.0.0-beta.17",
32
32
  "@vscode/ripgrep": "1.18.0"
33
33
  },
34
34
  "publishConfig": {