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

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.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # TheGitAI
2
2
 
3
- Interactive terminal coding agent. Talk to your repo in plain English — it
4
- reads code, edits files, and runs commands with your approval.
3
+ Interactive terminal coding agent. Talk to your repo in plain English — it reads
4
+ your code, makes edits, runs the commands a task needs, and keeps long-running
5
+ processes going while you work, all with your approval.
5
6
 
6
7
  ## Install
7
8
 
@@ -25,6 +26,22 @@ ai --version print the version and exit
25
26
 
26
27
  Run `ai --help` for sessions, modes, keys, and chat commands.
27
28
 
29
+ ## Background jobs
30
+
31
+ Some commands are meant to keep running — a dev server, a file watcher, a local
32
+ API. TheGitAI runs these as **background jobs**, so the agent can start a server,
33
+ work against it live, and stop it when the task is done, all in one session.
34
+
35
+ - Each job's block in the transcript updates on its own with a live tail of its
36
+ latest output, and a compact indicator keeps you aware of what's still running.
37
+ - Type `/jobs` to open a picker: **↑ / ↓** to move, **Enter** to expand a job and
38
+ read its output inline, **k** to stop it, **Esc** to close.
39
+ - `/jobs output <id>` prints a job's full output, and `/jobs kill <id>` stops one
40
+ by id.
41
+ - When you end the session, TheGitAI stops its background jobs for you.
42
+
43
+ Full documentation: <https://thegit.ai/docs>
44
+
28
45
  ## License
29
46
 
30
47
  Proprietary — see the LICENSE file included in this package. Source is
@@ -118,9 +118,7 @@ export function buildAgentModeToolBlockedResult(mode, call) {
118
118
  }
119
119
  if (call.name !== 'run_command')
120
120
  return null;
121
- if (call.args?.background === true ||
122
- call.args?.run_in_background === true ||
123
- call.args?.runInBackground === true) {
121
+ if (call.args?.background === true) {
124
122
  return buildPlanModeToolBlockedResult(call.name, PLAN_MODE_RUN_COMMAND_ACTION);
125
123
  }
126
124
  const command = String(call.args?.command ?? call.args?.cmd ?? '');
@@ -73,10 +73,10 @@ const HELP_MARKDOWN = [
73
73
  '- `/model` — list supported models and pick one',
74
74
  '- `/model <id>` — switch the active model without clearing history',
75
75
  '- `/resume` — resume a saved session for this repo',
76
- '- `/jobs` — open the background jobs picker (Enter expands/collapses,',
77
- ' k kills the selected job)',
76
+ '- `/jobs` — manage long-running commands like dev servers and watchers:',
77
+ ' browse them, press Enter to expand one and read its output, k to stop it',
78
78
  '- `/jobs output <id>` — print one job\'s full captured output',
79
- '- `/jobs kill <id>` — kill one background job',
79
+ '- `/jobs kill <id>` — stop one background job',
80
80
  '- `/clear` — clear the current conversation history',
81
81
  '- `/exit` — quit the session',
82
82
  '',
@@ -13,7 +13,7 @@ export async function runShellCommand(context, args) {
13
13
  if (!command) {
14
14
  return { ok: false, error: 'command is required' };
15
15
  }
16
- const runInBackground = args.background === true || args.run_in_background === true;
16
+ const runInBackground = args.background === true;
17
17
  const hasTimeout = typeof args.timeout_ms === 'number' && args.timeout_ms > 0;
18
18
  const repoHint = buildNestedGitHint(rootDir, command);
19
19
  const blockedReason = getBlockedCommandReason(command, hasTimeout || runInBackground, rootDir);
@@ -40,7 +40,7 @@ export async function runShellCommand(context, args) {
40
40
  };
41
41
  }
42
42
  const approved = await confirmCommand(runInBackground
43
- ? `${command}\n\nRuns as a managed background job until it exits or is stopped.`
43
+ ? `${command}\n\nRuns as a managed background job until it exits or is killed.`
44
44
  : command);
45
45
  if (!approved) {
46
46
  if (!isTuiMode())
@@ -102,6 +102,7 @@ async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
102
102
  const { rootDir, projectIndex, onStatus } = context;
103
103
  const started = await startBackgroundJob(command, rootDir, {
104
104
  startupWaitMs: typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : undefined,
105
+ sessionId: context.sessionId,
105
106
  });
106
107
  if (!started.ok || !started.snapshot) {
107
108
  if (!isTuiMode())
@@ -287,6 +287,17 @@ function footerTransientStatus(status) {
287
287
  }
288
288
  return null;
289
289
  }
290
+ function backgroundJobIndicatorSpans(state) {
291
+ const runningCount = (state.backgroundJobs ?? []).filter((job) => job.status === 'running').length;
292
+ if (runningCount === 0)
293
+ return [];
294
+ const noun = runningCount === 1 ? 'shell' : 'shells';
295
+ return [
296
+ span(' ', { color: 'gray', dim: true }),
297
+ span(`● ${runningCount} ${noun} running`, { color: 'green', bold: true }),
298
+ span(' · /jobs', { color: 'gray', dim: true }),
299
+ ];
300
+ }
290
301
  function composerFooterLines(state) {
291
302
  const visibleSessionId = formatPromptSessionIdLabel(state.showSessionId, state.sessionId);
292
303
  const transientStatus = footerTransientStatus(state.status);
@@ -305,7 +316,7 @@ function composerFooterLines(state) {
305
316
  ]
306
317
  : []), ...(visibleSessionId
307
318
  ? [span(` ${visibleSessionId}`, { color: 'gray', dim: true })]
308
- : [])),
319
+ : []), ...backgroundJobIndicatorSpans(state)),
309
320
  plainLine(''),
310
321
  plainLine(formatModelLabel(state.currentModelId, state.serverModels), {
311
322
  color: 'cyan',
@@ -352,20 +363,22 @@ export function formatJobElapsed(elapsedMs) {
352
363
  }
353
364
  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
354
365
  }
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
+ function backgroundJobPreviewLines(job, maxTailLines) {
367
+ const lines = [];
368
+ const first = String(job.firstOutputLine ?? '').trim();
369
+ if (first) {
370
+ lines.push(first);
371
+ }
372
+ for (const outputLine of (job.tailLines ?? []).slice(-maxTailLines)) {
373
+ const trimmed = String(outputLine ?? '').trim();
374
+ if (!trimmed)
375
+ continue;
376
+ if (lines.length === 1 && trimmed === first)
377
+ continue;
378
+ lines.push(trimmed);
379
+ }
380
+ return lines;
366
381
  }
367
- const JOB_LIVE_TAIL_LINES = 8;
368
- const JOB_FINISHED_TAIL_LINES = 3;
369
382
  function jobStatusDescriptor(job, nowMs) {
370
383
  const ran = formatJobElapsed((job.endedAt ?? (nowMs > 0 ? nowMs : Date.now())) - job.startedAt);
371
384
  if (job.status === 'running') {
@@ -381,23 +394,6 @@ function jobStatusDescriptor(job, nowMs) {
381
394
  ? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
382
395
  : { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
383
396
  }
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
397
  function buildJobsPickerLines(state, width, nowMs) {
402
398
  const jobs = state.backgroundJobs ?? [];
403
399
  const lines = [
@@ -408,13 +404,30 @@ function buildJobsPickerLines(state, width, nowMs) {
408
404
  }
409
405
  for (const [index, job] of jobs.entries()) {
410
406
  const selected = index === state.jobsPickerIndex;
407
+ const expanded = state.jobsPickerExpandedId === job.id;
411
408
  const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
412
409
  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
410
  color: selected ? 'cyan' : undefined,
414
411
  bold: selected,
415
412
  }), span(` ${text}`, { color: 'gray' })));
413
+ if (expanded) {
414
+ const detailLines = (job.detailLines ?? []).length
415
+ ? job.detailLines ?? []
416
+ : backgroundJobPreviewLines(job, 3);
417
+ if (detailLines.length === 0) {
418
+ lines.push(plainLine(' (no output captured)', { color: 'gray' }));
419
+ }
420
+ else {
421
+ for (const outputLine of detailLines) {
422
+ lines.push(line(span(' │ ', { color: 'gray', dim: true }), span(truncate(outputLine, Math.max(8, width - 8)), {
423
+ color: 'gray',
424
+ dim: true,
425
+ })));
426
+ }
427
+ }
428
+ }
416
429
  }
417
- lines.push(plainLine('↑/↓ move enter output k kill esc close', {
430
+ lines.push(plainLine('↑/↓ move enter expand/collapse k kill esc close', {
418
431
  color: 'gray',
419
432
  }));
420
433
  return lines;
@@ -751,9 +764,7 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
751
764
  export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
752
765
  const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
753
766
  const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
754
- const transcriptBlocks = state.transcript.map((entry) => entry.jobId
755
- ? renderBackgroundJobEntryLines(entry, state, contentWidth, nowMs)
756
- : renderTranscriptEntryLines(entry, contentWidth));
767
+ const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
757
768
  const transcriptLines = [];
758
769
  transcriptBlocks.forEach((block, index) => {
759
770
  if (index > 0) {
@@ -768,10 +779,6 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
768
779
  }
769
780
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
770
781
  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
- }
775
782
  const composerLines = [];
776
783
  if (state.queuedMessage) {
777
784
  const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
@@ -216,6 +216,7 @@ export function handleShellKeyEvent(store, handlers, event) {
216
216
  if (key.escape) {
217
217
  store.update((current) => ({
218
218
  ...current,
219
+ jobsPickerExpandedId: null,
219
220
  jobsPickerOpen: false,
220
221
  status: 'Ready',
221
222
  }));
@@ -227,6 +228,7 @@ export function handleShellKeyEvent(store, handlers, event) {
227
228
  const next = current.jobsPickerIndex + (key.upArrow ? -1 : 1);
228
229
  return {
229
230
  ...current,
231
+ jobsPickerExpandedId: null,
230
232
  jobsPickerIndex: Math.max(0, Math.min(next, Math.max(count - 1, 0))),
231
233
  };
232
234
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-beta.17",
3
+ "version": "1.0.0-beta.19",
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.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",
28
+ "@thegitai/tui-darwin-arm64": "1.0.0-beta.19",
29
+ "@thegitai/tui-darwin-x64": "1.0.0-beta.19",
30
+ "@thegitai/tui-linux-x64": "1.0.0-beta.19",
31
+ "@thegitai/tui-win32-x64": "1.0.0-beta.19",
32
32
  "@vscode/ripgrep": "1.18.0"
33
33
  },
34
34
  "publishConfig": {