@thegitai/cli 1.0.0-beta.13 → 1.0.0-beta.14

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.
@@ -3,6 +3,7 @@ import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js'
3
3
  import { executeLocalToolCall } from '../tool-executor.js';
4
4
  import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
5
5
  import { collectClientEnvironment } from '../client-environment.js';
6
+ import { autoAttachImages } from '../core/image-path-extractor.js';
6
7
  export class TurnCancelledError extends Error {
7
8
  name = 'TurnCancelledError';
8
9
  constructor(message = 'Turn cancelled.') {
@@ -63,6 +64,9 @@ function snapshotForServer(session) {
63
64
  snapshot.clientState.safety = sanitizeSessionSafetyForServer(snapshot.clientState.safety);
64
65
  return snapshot;
65
66
  }
67
+ function imageAttachmentsForServer(attachments) {
68
+ return (attachments ?? []).map(({ filePath: _filePath, ...attachment }) => attachment);
69
+ }
66
70
  function userHistoryText(entry) {
67
71
  return (entry.parts ?? [])
68
72
  .map((part) => (typeof part?.text === 'string' ? part.text : ''))
@@ -285,19 +289,33 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
285
289
  return finalResult.current;
286
290
  }
287
291
  export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
292
+ const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
293
+ const requestImageAttachments = autoAttach.attachments.length > 0
294
+ ? [...imageAttachments, ...autoAttach.attachments]
295
+ : imageAttachments;
296
+ // requestInput is the server-bound, path-sanitized text ([Image #N] markers in
297
+ // place of local image paths). Every history-preservation path below (abort,
298
+ // cancel, failure, mid-stream tool cancel) must persist THIS, never the raw
299
+ // input — otherwise a cancelled or failed image turn leaks the local
300
+ // filesystem path into session history and the next snapshot ships it to the
301
+ // server, defeating the point of sanitizing before the request crosses over.
302
+ const requestInput = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
303
+ for (const err of autoAttach.errors) {
304
+ session.onStatus(`Image: ${err}`);
305
+ }
288
306
  const request = {
289
307
  modelId: session.modelId,
290
308
  session: snapshotForServer(session),
291
- input,
309
+ input: requestInput,
292
310
  clientEnvironment: collectClientEnvironment({ env: session.env }),
293
- imageAttachments,
311
+ imageAttachments: imageAttachmentsForServer(requestImageAttachments),
294
312
  maxToolSteps: session.maxToolSteps,
295
313
  autoYes: session.autoYes,
296
314
  agentMode: session.agentMode,
297
315
  };
298
316
  const trace = createTraceContext();
299
317
  const preTurnHistoryLength = session.history.length;
300
- const preserveOnAbort = () => preserveCancelledTurnInput(session, input);
318
+ const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInput);
301
319
  if (signal?.aborted) {
302
320
  preserveOnAbort();
303
321
  }
@@ -324,7 +342,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
324
342
  config,
325
343
  projectIndex,
326
344
  session,
327
- input,
345
+ input: requestInput,
328
346
  fetchImpl,
329
347
  signal,
330
348
  traceId: trace.traceId,
@@ -339,7 +357,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
339
357
  }
340
358
  catch (error) {
341
359
  if (isTurnCancelledError(error)) {
342
- preserveCancelledTurnInput(session, input);
360
+ preserveCancelledTurnInput(session, requestInput);
343
361
  throw error instanceof TurnCancelledError
344
362
  ? error
345
363
  : new TurnCancelledError();
@@ -348,7 +366,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
348
366
  // speculative cancelled-turn entries in history — otherwise the next
349
367
  // request replays a malformed transcript to the server.
350
368
  session.history.length = preTurnHistoryLength;
351
- preserveFailedTurnInput(session, input, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
369
+ preserveFailedTurnInput(session, requestInput, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
352
370
  throw error;
353
371
  }
354
372
  finally {
@@ -1,4 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
2
4
  const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
3
5
  const MIME_BY_EXT = {
4
6
  '.png': 'image/png',
@@ -262,3 +264,20 @@ export function writeClipboardText(text, platform = process.platform) {
262
264
  }
263
265
  throw new ClipboardError(`Clipboard text copy is not supported on ${platform}.`, 'NO_TOOL');
264
266
  }
267
+ export function loadImageFromFile(filePath) {
268
+ const resolved = path.resolve(filePath);
269
+ if (!existsSync(resolved)) {
270
+ throw new ClipboardError(`Image file not found: ${resolved}`, 'READ_FAILED');
271
+ }
272
+ const stat = statSync(resolved);
273
+ if (stat.size > MAX_IMAGE_SIZE_BYTES) {
274
+ throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
275
+ }
276
+ const ext = path.extname(resolved).toLowerCase();
277
+ const mimeType = MIME_BY_EXT[ext];
278
+ if (!mimeType) {
279
+ throw new ClipboardError(`Unsupported image format "${ext}". Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
280
+ }
281
+ const buf = readFileSync(resolved);
282
+ return { base64Data: buf.toString('base64'), mimeType };
283
+ }
@@ -0,0 +1,112 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadImageFromFile } from './clipboard.js';
5
+ const EXT = '(?:png|jpe?g|gif|webp)';
6
+ // Bare-path characters: exclude whitespace, quotes, prose terminators, ':' (so we
7
+ // don't swallow URL schemes or trailing "see foo.png:" prose) and bracket/brace/paren
8
+ // delimiters on both sides (so markdown "![alt](x.png)" / "(x.png)" don't leak in).
9
+ const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
10
+ // A bare token may carry backslash-escaped spaces (terminal drag-and-drop) and an
11
+ // optional Windows drive prefix ("C:\\" or "C:/").
12
+ const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
13
+ const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
14
+ `|'([^']*\\.${EXT})'` +
15
+ `|file://(\\S*\\.${EXT})` +
16
+ `|(${BARE_PATH})`, 'gi');
17
+ function detectImagePaths(input, cwd) {
18
+ const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
19
+ const rawsByPath = new Map();
20
+ let match;
21
+ while ((match = regex.exec(input)) !== null) {
22
+ const raw = match[0];
23
+ // A bare match containing "://" is a URL scheme remnant (e.g. the "p://" tail
24
+ // of "http://…" caught by the drive-letter prefix), never a local file path.
25
+ // Scope this to the bare branch only — a quoted value may legitimately be a
26
+ // file:// URL, which the quoted branch below decodes.
27
+ if (match[4] != null && raw.includes('://'))
28
+ continue;
29
+ let inner;
30
+ if (match[1] != null || match[2] != null) {
31
+ const quoted = (match[1] ?? match[2]);
32
+ // A quoted value can itself be a file:// URL ("file:///a/b%20c.png"); it
33
+ // still needs percent-decoding, so route it through the same decoder.
34
+ if (/^file:\/\//i.test(quoted)) {
35
+ try {
36
+ inner = fileURLToPath(quoted);
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ }
42
+ else {
43
+ inner = quoted;
44
+ }
45
+ }
46
+ else if (match[3] != null) {
47
+ // file:// URL: decode percent-escapes (e.g. "%20") to the real filesystem
48
+ // path. A malformed URL (bad %-escape) must be skipped, never thrown:
49
+ // ordinary prose containing a broken file:// URL should not abort the turn.
50
+ try {
51
+ inner = fileURLToPath(raw);
52
+ }
53
+ catch {
54
+ try {
55
+ inner = decodeURIComponent(match[3]);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ }
61
+ }
62
+ else {
63
+ // Bare path: unescape "\\ " sequences produced by terminal drag-and-drop.
64
+ inner = match[4].replace(/\\ /g, ' ');
65
+ }
66
+ const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
67
+ const existing = rawsByPath.get(resolvedPath);
68
+ if (existing) {
69
+ existing.push(raw);
70
+ }
71
+ else {
72
+ rawsByPath.set(resolvedPath, [raw]);
73
+ }
74
+ }
75
+ return rawsByPath;
76
+ }
77
+ export function autoAttachImages(input, cwd, existing = []) {
78
+ const max = 2;
79
+ const rawsByPath = detectImagePaths(input, cwd);
80
+ let sanitizedInput = input;
81
+ const attachments = [];
82
+ const errors = [];
83
+ // Seed new markers after the highest existing index, not the existing count.
84
+ // The TUI can submit a sparse attachment set (e.g. [Image #2] kept after
85
+ // [Image #1] was deleted from the prompt), so counting would reuse an index
86
+ // still present in the prompt and make the model inspect the wrong image.
87
+ const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
88
+ for (const [resolvedPath, rawForms] of rawsByPath) {
89
+ if (existing.length + attachments.length >= max)
90
+ break;
91
+ if (!existsSync(resolvedPath))
92
+ continue;
93
+ try {
94
+ const loaded = loadImageFromFile(resolvedPath);
95
+ const idx = maxExistingIndex + attachments.length + 1;
96
+ attachments.push({
97
+ index: idx,
98
+ mimeType: loaded.mimeType,
99
+ base64Data: loaded.base64Data,
100
+ source: 'file',
101
+ filePath: resolvedPath,
102
+ });
103
+ for (const raw of rawForms) {
104
+ sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
105
+ }
106
+ }
107
+ catch (err) {
108
+ errors.push(err.message);
109
+ }
110
+ }
111
+ return { sanitizedInput, attachments, errors };
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-beta.13",
3
+ "version": "1.0.0-beta.14",
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.13",
29
- "@thegitai/tui-darwin-x64": "1.0.0-beta.13",
30
- "@thegitai/tui-linux-x64": "1.0.0-beta.13",
31
- "@thegitai/tui-win32-x64": "1.0.0-beta.13",
28
+ "@thegitai/tui-darwin-arm64": "1.0.0-beta.14",
29
+ "@thegitai/tui-darwin-x64": "1.0.0-beta.14",
30
+ "@thegitai/tui-linux-x64": "1.0.0-beta.14",
31
+ "@thegitai/tui-win32-x64": "1.0.0-beta.14",
32
32
  "@vscode/ripgrep": "1.18.0"
33
33
  },
34
34
  "publishConfig": {