@thegitai/cli 1.0.0-preview.25 → 1.0.0-preview.26

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.
@@ -2,6 +2,7 @@ import { drainBackgroundJobNotifications } from '../background-jobs.js';
2
2
  import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
3
3
  import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
4
4
  import { executeLocalToolCall } from '../tool-executor.js';
5
+ import { saveGeneratedImage } from '../tools/save-generated-image.js';
5
6
  import { isUserInputQuestionArray } from './contracts.js';
6
7
  import { createTraceContext, gatewayFailureCategory, normalizeServerUrl, readErrorResponse, } from './http.js';
7
8
  import { collectClientEnvironment } from '../client-environment.js';
@@ -165,6 +166,9 @@ function publicStatusMessage(data) {
165
166
  if (event.phase === 'analyzing_image') {
166
167
  return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
167
168
  }
169
+ if (event.phase === 'generating_image') {
170
+ return 'Generating image...';
171
+ }
168
172
  if (event.phase === 'running_tool')
169
173
  return `Running ${toolName}...`;
170
174
  if (event.phase === 'waiting_for_tool')
@@ -312,7 +316,14 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
312
316
  }
313
317
  try {
314
318
  const call = normalizeShellJobToolCall(event.call);
315
- const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
319
+ const rawResult = event.generatedImage
320
+ ? saveGeneratedImage({
321
+ base64Data: event.generatedImage.base64Data,
322
+ mimeType: event.generatedImage.mimeType,
323
+ suggestedFilename: event.generatedImage.suggestedFilename ||
324
+ String(call.args?.filename ?? call.args?.file_name ?? ''),
325
+ })
326
+ : await executeLocalToolCall({ projectIndex }, session, call);
316
327
  preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
317
328
  if (signal?.aborted) {
318
329
  throw new TurnCancelledError();
@@ -383,9 +394,15 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
383
394
  const data = event.data;
384
395
  if (data?.phase === 'analyzing_image') {
385
396
  session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
397
+ session.onImageGeneration?.(false);
398
+ }
399
+ else if (data?.phase === 'generating_image') {
400
+ session.onImageGeneration?.(true);
401
+ session.onImageAnalysis?.(0);
386
402
  }
387
403
  else if (data?.phase) {
388
404
  session.onImageAnalysis?.(0);
405
+ session.onImageGeneration?.(false);
389
406
  }
390
407
  const message = publicStatusMessage(event.data);
391
408
  if (message)
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
4
4
  import path from 'node:path';
5
5
  import { getClientStateDir } from './client-state.js';
6
6
  import { findStoredImageByContent, pruneSessionImages, readSessionImage, sweepOrphanSessionImages, } from './core/session-image-store.js';
7
+ import { sweepGeneratedImages } from './tools/save-generated-image.js';
7
8
  import { normalizeAssistantEditJournal } from './edit-journal.js';
8
9
  import { createSessionGrants } from './permissions.js';
9
10
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
@@ -224,6 +225,7 @@ export function pruneSavedSessions(rootDir, env = process.env) {
224
225
  activeSessionIds: listAllSessionIds(env),
225
226
  env,
226
227
  });
228
+ sweepGeneratedImages({ env });
227
229
  }
228
230
  export function readGitBranch(rootDir) {
229
231
  try {
@@ -23,6 +23,7 @@ import { strReplace } from './str-replace.js';
23
23
  import { undoEdit } from './undo-edit.js';
24
24
  import { updateTodos } from './update-todos.js';
25
25
  import { readImageFile } from './read-image-file.js';
26
+ import { saveGeneratedImage } from './save-generated-image.js';
26
27
  import { writeFile } from './write-file.js';
27
28
  export const TOOL_MAP = {
28
29
  search_code: (context, args) => searchCode(context.projectIndex, args),
@@ -52,6 +53,16 @@ export const TOOL_MAP = {
52
53
  shell_job_kill: shellJobKill,
53
54
  update_todos: updateTodos,
54
55
  analyze_image: (context, args) => readImageFile(context, args),
56
+ generate_image: (_context, args) => {
57
+ if (!String(args.base64Data ?? '').trim()) {
58
+ return {
59
+ ok: false,
60
+ error: 'This CLI build cannot generate images without server-supplied image bytes. Update TheGitAI CLI.',
61
+ failureCategory: 'tool_exception',
62
+ };
63
+ }
64
+ return saveGeneratedImage(args);
65
+ },
55
66
  };
56
67
  function invalidToolCall(error) {
57
68
  return {
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getClientStateDir } from '../client-state.js';
4
+ import { MAX_IMAGE_SIZE_BYTES, sniffImageMimeType, } from '../core/image-limits.js';
5
+ const IMAGE_FILE_MODE = 0o600;
6
+ const IMAGE_DIR_MODE = 0o700;
7
+ export const GENERATED_IMAGES_SUBDIR = 'generated_images';
8
+ export const GENERATED_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
9
+ export function getGeneratedImagesDir(env = process.env) {
10
+ return path.join(getClientStateDir(env), GENERATED_IMAGES_SUBDIR);
11
+ }
12
+ export function sanitizeGeneratedImageBasename(raw) {
13
+ const trimmed = String(raw ?? '').trim();
14
+ const base = trimmed
15
+ .replace(/\\/g, '/')
16
+ .split('/')
17
+ .pop()
18
+ ?.replace(/^\.+/, '')
19
+ .replace(/[^\w.\-]+/g, '-')
20
+ .replace(/-+/g, '-')
21
+ .replace(/^-+|-+$/g, '')
22
+ .replace(/-\.|\.-/g, '.')
23
+ .replace(/^-+|-+$/g, '')
24
+ .slice(0, 80);
25
+ if (!base || base === '.' || base === '..') {
26
+ return `generated-${Date.now()}.png`;
27
+ }
28
+ if (/\.png$/i.test(base)) {
29
+ return base.replace(/\.png$/i, '.png');
30
+ }
31
+ const withoutExt = base.replace(/\.[^.]+$/, '').replace(/-+$/g, '');
32
+ return `${withoutExt || 'generated'}.png`;
33
+ }
34
+ function uniquePath(dir, basename) {
35
+ const candidate = path.join(dir, basename);
36
+ if (!existsSync(candidate)) {
37
+ return candidate;
38
+ }
39
+ const ext = path.extname(basename) || '.png';
40
+ const stem = path.basename(basename, ext);
41
+ for (let i = 2; i < 10_000; i += 1) {
42
+ const next = path.join(dir, `${stem}-${i}${ext}`);
43
+ if (!existsSync(next)) {
44
+ return next;
45
+ }
46
+ }
47
+ return path.join(dir, `${stem}-${Date.now()}${ext}`);
48
+ }
49
+ export function sweepGeneratedImages({ maxAgeMs = GENERATED_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), } = {}) {
50
+ const dir = getGeneratedImagesDir(env);
51
+ if (!existsSync(dir))
52
+ return 0;
53
+ let removed = 0;
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(dir);
57
+ }
58
+ catch {
59
+ return 0;
60
+ }
61
+ for (const entry of entries) {
62
+ const filePath = path.join(dir, entry);
63
+ try {
64
+ if (now - statSync(filePath).mtimeMs < maxAgeMs)
65
+ continue;
66
+ rmSync(filePath, { force: true });
67
+ removed += 1;
68
+ }
69
+ catch {
70
+ }
71
+ }
72
+ return removed;
73
+ }
74
+ export function saveGeneratedImage(args) {
75
+ const base64Data = String(args.base64Data ?? '').trim();
76
+ if (!base64Data) {
77
+ return {
78
+ ok: false,
79
+ error: 'This CLI build cannot generate images without server-supplied image bytes. Update TheGitAI CLI.',
80
+ failureCategory: 'tool_exception',
81
+ };
82
+ }
83
+ let bytes;
84
+ try {
85
+ bytes = Buffer.from(base64Data, 'base64');
86
+ }
87
+ catch {
88
+ return {
89
+ ok: false,
90
+ error: 'Generated image bytes are invalid.',
91
+ failureCategory: 'invalid_argument',
92
+ };
93
+ }
94
+ if (bytes.length === 0 || bytes.length > MAX_IMAGE_SIZE_BYTES) {
95
+ return {
96
+ ok: false,
97
+ error: 'Generated image exceeded the allowed size limit.',
98
+ failureCategory: 'invalid_argument',
99
+ };
100
+ }
101
+ const sniffed = sniffImageMimeType(bytes);
102
+ if (sniffed !== 'image/png') {
103
+ return {
104
+ ok: false,
105
+ error: 'Generated image must be a PNG.',
106
+ failureCategory: 'invalid_argument',
107
+ };
108
+ }
109
+ const env = args.env ?? process.env;
110
+ const dir = getGeneratedImagesDir(env);
111
+ mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
112
+ const basename = sanitizeGeneratedImageBasename(args.suggestedFilename ?? args.filename);
113
+ const target = uniquePath(dir, basename);
114
+ writeFileSync(target, bytes, { mode: IMAGE_FILE_MODE });
115
+ return {
116
+ ok: true,
117
+ path: target,
118
+ message: `Image saved to ${target}`,
119
+ };
120
+ }
@@ -982,6 +982,7 @@ function createInitialShellState(session, serverModels, debugUi) {
982
982
  activeTurnInputPreformatted: false,
983
983
  agentMode: session.agentMode,
984
984
  analyzingImages: 0,
985
+ generatingImage: false,
985
986
  approvalCursor: 0,
986
987
  approvalPrompt: null,
987
988
  approvalScrollOffset: 0,
@@ -2623,6 +2624,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2623
2624
  activeTurnInput: input,
2624
2625
  activeTurnInputPreformatted: preformatted,
2625
2626
  analyzingImages: 0,
2627
+ generatingImage: false,
2626
2628
  busy: true,
2627
2629
  busyPausedAt: null,
2628
2630
  busySince: turnStartedAt,
@@ -2777,6 +2779,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2777
2779
  store.update((current) => ({
2778
2780
  ...current,
2779
2781
  analyzingImages: Math.max(0, activeImageCount),
2782
+ generatingImage: activeImageCount > 0 ? false : current.generatingImage,
2783
+ }));
2784
+ };
2785
+ session.onImageGeneration = (active) => {
2786
+ store.update((current) => ({
2787
+ ...current,
2788
+ generatingImage: Boolean(active),
2789
+ analyzingImages: active ? 0 : current.analyzingImages,
2780
2790
  }));
2781
2791
  };
2782
2792
  session.onStatus = (message) => {
@@ -953,11 +953,13 @@ export function buildWorkingClockLine(state, elapsedSeconds) {
953
953
  if (state.busyPausedAt != null) {
954
954
  return `${WORKING_CLOCK_ICON} Paused · ${elapsed} · waiting for your response`;
955
955
  }
956
- const label = state.analyzingImages > 0
957
- ? state.analyzingImages > 1
958
- ? 'Analyzing images'
959
- : 'Analyzing image'
960
- : 'Working';
956
+ const label = state.generatingImage
957
+ ? 'Generating image'
958
+ : state.analyzingImages > 0
959
+ ? state.analyzingImages > 1
960
+ ? 'Analyzing images'
961
+ : 'Analyzing image'
962
+ : 'Working';
961
963
  return `${WORKING_CLOCK_ICON} ${label} · ${elapsed}`;
962
964
  }
963
965
  export function approvalPanelInnerWidth(width) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.25",
3
+ "version": "1.0.0-preview.26",
4
4
  "description": "TheGitAI is an AI coding agent for your terminal. It indexes your repository, writes and edits files, runs commands, and builds features with you.",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,10 +37,10 @@
37
37
  "@lydell/node-pty-linux-x64": "1.1.0",
38
38
  "@lydell/node-pty-win32-arm64": "1.1.0",
39
39
  "@lydell/node-pty-win32-x64": "1.1.0",
40
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.25",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.25",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.25",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.25",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.26",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.26",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.26",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.26",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {