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

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/dist/bin/ai.js CHANGED
@@ -4,6 +4,7 @@ import { stdin as input, stdout as output } from 'node:process';
4
4
  import readline from 'node:readline/promises';
5
5
  import { ServerApi } from '../src/api/index.js';
6
6
  import { loginViaBrowser } from '../src/api/browser-login.js';
7
+ import { isTransientNetworkError } from '../src/api/http.js';
7
8
  import { formatCliHelpText, formatInteractiveHelpText, } from '../src/help-text.js';
8
9
  import { renderMarkdownForTerminal } from '../src/markdown-renderer.js';
9
10
  import { createIndex } from '../src/project-index.js';
@@ -36,9 +37,6 @@ function appendPromptHistory(prompt, env = process.env) {
36
37
  }
37
38
  async function runAuthCommand(command, args) {
38
39
  if (command === 'login') {
39
- // The public CLI always authenticates against the official TheGitAI host.
40
- // There is intentionally no server/website override here — internal dev
41
- // uses private tooling, not a customer-visible runtime override path.
42
40
  const serverUrl = DEFAULT_SERVER_URL;
43
41
  const noBrowser = args.includes('--no-browser');
44
42
  console.log(chalk.dim(noBrowser
@@ -314,9 +312,46 @@ export async function main() {
314
312
  const rootDir = process.cwd();
315
313
  const authConfig = requireCliAuthConfig();
316
314
  const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
317
- const cachedModels = models.readCachedServerModels();
318
- const serverModels = await models.fetchServerModels({ config: authConfig });
319
- const whoami = await auth.fetchWhoamiResponse({ config: authConfig });
315
+ const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
316
+ let offlineNotice = null;
317
+ let serverModels;
318
+ try {
319
+ serverModels = await models.fetchServerModels({ config: authConfig });
320
+ }
321
+ catch (error) {
322
+ if (isTransientNetworkError(error) && cachedModels?.models.length) {
323
+ serverModels = { models: cachedModels.models };
324
+ offlineNotice = error?.message ? String(error.message) : 'network error';
325
+ }
326
+ else {
327
+ throw error;
328
+ }
329
+ }
330
+ let whoami;
331
+ try {
332
+ whoami = await auth.fetchWhoamiResponse({ config: authConfig });
333
+ }
334
+ catch (error) {
335
+ if (isTransientNetworkError(error)) {
336
+ whoami = {
337
+ customer: {
338
+ id: '',
339
+ uuid: '',
340
+ email: authConfig.email,
341
+ customer_type: authConfig.customerType ?? 'USER',
342
+ scopes: [],
343
+ },
344
+ debugUi: { showSessionId: false },
345
+ };
346
+ offlineNotice ??= error?.message ? String(error.message) : 'network error';
347
+ }
348
+ else {
349
+ throw error;
350
+ }
351
+ }
352
+ if (offlineNotice) {
353
+ console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
354
+ }
320
355
  if (listSessions) {
321
356
  printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
322
357
  return;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getClientStateDir } from '../client-state.js';
4
- import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
4
+ import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
5
5
  export function getAuthConfigPath(env = process.env) {
6
6
  const configured = String(env.THEGITAI_AUTH_CONFIG ?? '').trim();
7
7
  if (configured) {
@@ -51,11 +51,11 @@ export async function fetchWhoami({ config, fetchImpl = globalThis.fetch, }) {
51
51
  return data.customer;
52
52
  }
53
53
  export async function fetchWhoamiResponse({ config, fetchImpl = globalThis.fetch, }) {
54
- const data = (await authorizedJson({
54
+ const data = (await retryTransient(() => authorizedJson({
55
55
  config,
56
56
  path: '/v1/auth/whoami',
57
57
  fetchImpl,
58
- }));
58
+ })));
59
59
  if (!data?.customer?.email) {
60
60
  throw new Error('Server returned an invalid whoami response.');
61
61
  }
@@ -7,15 +7,10 @@ const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
7
7
  const DEFAULT_SERVER_URL = 'https://thegit.ai';
8
8
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
9
9
  function shutDownServer(server) {
10
- // Drop any lingering (keep-alive) connections so the event loop empties and
11
- // the CLI exits instead of hanging after a successful login.
12
10
  server.closeAllConnections?.();
13
11
  server.close();
14
12
  }
15
13
  export function resolveWebsiteUrl() {
16
- // The public CLI always signs in through the official TheGitAI website. There
17
- // is no override path here so the published package cannot be pointed at a
18
- // clone host.
19
14
  return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
20
15
  }
21
16
  function defaultDeviceName() {
@@ -26,7 +21,6 @@ function defaultDeviceName() {
26
21
  return os.hostname();
27
22
  }
28
23
  }
29
- /** PKCE (RFC 7636, S256): a random verifier and its SHA-256 challenge. */
30
24
  export function generatePkce() {
31
25
  const verifier = crypto.randomBytes(32).toString('base64url');
32
26
  const challenge = crypto
@@ -77,12 +71,6 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
77
71
  customer,
78
72
  };
79
73
  }
80
- /**
81
- * Browser-based login. Starts a loopback server so the website can redirect the
82
- * one-time code back automatically; the code is then exchanged for a token
83
- * using the PKCE verifier. With `noBrowser`, the user pastes the code instead.
84
- * The CLI never sees the user's credentials.
85
- */
86
74
  export async function loginViaBrowser(options) {
87
75
  const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
88
76
  const websiteUrl = resolveWebsiteUrl();
@@ -97,8 +85,6 @@ export async function loginViaBrowser(options) {
97
85
  deviceName,
98
86
  paste: true,
99
87
  });
100
- // Headless mode: only print the URL for the user to open on another device.
101
- // Never launch a browser here — that is the whole point of --no-browser.
102
88
  onUrl(authUrl);
103
89
  if (!options.promptCode) {
104
90
  throw new Error('No way to read the authorization code in this context.');
@@ -125,8 +111,6 @@ export async function loginViaBrowser(options) {
125
111
  }
126
112
  const code = requestUrl.searchParams.get('code') ?? '';
127
113
  const returnedState = requestUrl.searchParams.get('state') ?? '';
128
- // `Connection: close` plus closeAllConnections() ensures the browser's
129
- // keep-alive socket is torn down so the process can exit after login.
130
114
  if (!code || returnedState !== state) {
131
115
  res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
132
116
  res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run ai login again.'));
@@ -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,27 @@ 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
+ const requestInput = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
297
+ for (const err of autoAttach.errors) {
298
+ session.onStatus(`Image: ${err}`);
299
+ }
288
300
  const request = {
289
301
  modelId: session.modelId,
290
302
  session: snapshotForServer(session),
291
- input,
303
+ input: requestInput,
292
304
  clientEnvironment: collectClientEnvironment({ env: session.env }),
293
- imageAttachments,
305
+ imageAttachments: imageAttachmentsForServer(requestImageAttachments),
294
306
  maxToolSteps: session.maxToolSteps,
295
307
  autoYes: session.autoYes,
296
308
  agentMode: session.agentMode,
297
309
  };
298
310
  const trace = createTraceContext();
299
311
  const preTurnHistoryLength = session.history.length;
300
- const preserveOnAbort = () => preserveCancelledTurnInput(session, input);
312
+ const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInput);
301
313
  if (signal?.aborted) {
302
314
  preserveOnAbort();
303
315
  }
@@ -324,7 +336,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
324
336
  config,
325
337
  projectIndex,
326
338
  session,
327
- input,
339
+ input: requestInput,
328
340
  fetchImpl,
329
341
  signal,
330
342
  traceId: trace.traceId,
@@ -339,16 +351,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
339
351
  }
340
352
  catch (error) {
341
353
  if (isTurnCancelledError(error)) {
342
- preserveCancelledTurnInput(session, input);
354
+ preserveCancelledTurnInput(session, requestInput);
343
355
  throw error instanceof TurnCancelledError
344
356
  ? error
345
357
  : new TurnCancelledError();
346
358
  }
347
- // Non-cancel failures (e.g. upstream connection errors) must not leave
348
- // speculative cancelled-turn entries in history — otherwise the next
349
- // request replays a malformed transcript to the server.
350
359
  session.history.length = preTurnHistoryLength;
351
- preserveFailedTurnInput(session, input, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
360
+ preserveFailedTurnInput(session, requestInput, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
352
361
  throw error;
353
362
  }
354
363
  finally {
@@ -26,6 +26,53 @@ export function createTraceContext(traceId = createTraceId()) {
26
26
  },
27
27
  };
28
28
  }
29
+ export const REQUEST_TIMEOUT_MS = 8000;
30
+ const TRANSIENT_NETWORK_CODES = new Set([
31
+ 'ECONNRESET',
32
+ 'ECONNREFUSED',
33
+ 'ETIMEDOUT',
34
+ 'EAI_AGAIN',
35
+ 'ENOTFOUND',
36
+ 'ENETUNREACH',
37
+ 'EHOSTUNREACH',
38
+ 'EPIPE',
39
+ 'UND_ERR_CONNECT_TIMEOUT',
40
+ 'UND_ERR_SOCKET',
41
+ 'UND_ERR_HEADERS_TIMEOUT',
42
+ 'UND_ERR_BODY_TIMEOUT',
43
+ ]);
44
+ export function isTransientNetworkError(error) {
45
+ if (error instanceof ServerApiError)
46
+ return false;
47
+ if (!error || typeof error !== 'object')
48
+ return false;
49
+ const err = error;
50
+ if (err.name === 'AbortError' || err.name === 'TimeoutError')
51
+ return true;
52
+ const code = err.code ?? err.cause?.code;
53
+ if (code) {
54
+ return TRANSIENT_NETWORK_CODES.has(code);
55
+ }
56
+ if (error instanceof TypeError && /fetch failed/i.test(err.message ?? '')) {
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+ export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {}) {
62
+ let attempt = 0;
63
+ for (;;) {
64
+ try {
65
+ return await run();
66
+ }
67
+ catch (error) {
68
+ if (attempt >= retries || !isTransientNetworkError(error))
69
+ throw error;
70
+ const delayMs = baseDelayMs * 2 ** attempt;
71
+ attempt += 1;
72
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
73
+ }
74
+ }
75
+ }
29
76
  export function normalizeServerUrl(serverUrl) {
30
77
  const normalized = String(serverUrl || DEFAULT_SERVER_URL)
31
78
  .trim()
@@ -53,7 +100,7 @@ export async function readErrorResponse(response, traceId = response.headers.get
53
100
  const data = await readJsonResponse(response);
54
101
  return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
55
102
  }
56
- export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, }) {
103
+ export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
57
104
  const trace = createTraceContext();
58
105
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}${path}`, {
59
106
  method,
@@ -64,6 +111,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
64
111
  ...(body === null ? {} : { 'content-type': 'application/json' }),
65
112
  },
66
113
  body: body === null ? undefined : JSON.stringify(body),
114
+ signal: AbortSignal.timeout(timeoutMs),
67
115
  });
68
116
  const data = await readJsonResponse(response);
69
117
  if (!response.ok) {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getClientStateDir } from '../client-state.js';
4
- import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
4
+ import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
5
5
  function sanitizeModelInfo(raw) {
6
6
  if (!raw || typeof raw !== 'object') {
7
7
  return null;
@@ -45,6 +45,11 @@ export function readCachedServerModels(env = process.env) {
45
45
  return null;
46
46
  }
47
47
  }
48
+ export function selectCacheForServer(cached, serverUrl) {
49
+ if (!cached)
50
+ return null;
51
+ return cached.serverUrl === normalizeServerUrl(serverUrl) ? cached : null;
52
+ }
48
53
  export function writeCachedServerModels(cache, env = process.env) {
49
54
  const filePath = getModelsCachePath(env);
50
55
  mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -54,26 +59,27 @@ export function writeCachedServerModels(cache, env = process.env) {
54
59
  });
55
60
  }
56
61
  export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
57
- const trace = createTraceContext();
58
- const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
59
- headers: {
60
- authorization: `Bearer ${config.token}`,
61
- ...trace.headers,
62
- },
62
+ return retryTransient(async () => {
63
+ const trace = createTraceContext();
64
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
65
+ headers: {
66
+ authorization: `Bearer ${config.token}`,
67
+ ...trace.headers,
68
+ },
69
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
70
+ });
71
+ const data = (await readJsonResponse(response));
72
+ if (!response.ok) {
73
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
74
+ }
75
+ const models = Array.isArray(data?.models)
76
+ ? data.models.map(sanitizeModelInfo).filter(Boolean)
77
+ : [];
78
+ if (models.length === 0) {
79
+ throw new Error('Server returned an invalid model list.');
80
+ }
81
+ return { models };
63
82
  });
64
- const data = (await readJsonResponse(response));
65
- if (!response.ok) {
66
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
67
- }
68
- const models = Array.isArray(data?.models)
69
- ? data.models.map(sanitizeModelInfo).filter(Boolean)
70
- : [];
71
- if (models.length === 0) {
72
- throw new Error('Server returned an invalid model list.');
73
- }
74
- return {
75
- models,
76
- };
77
83
  }
78
84
  export function selectServerModel({ requestedModelId, cached, serverModels, }) {
79
85
  const supportedIds = new Set(serverModels.models.map((model) => model.id));
@@ -148,10 +148,6 @@ export const ARTIFACT_FALLBACK_IGNORE_GLOBS = [
148
148
  '**/pnpm-lock.yaml',
149
149
  ...ARTIFACT_IGNORE_PATH_PREFIXES.map((prefix) => `${prefix}/**`),
150
150
  ];
151
- // Kept in lockstep with the server's secret-path check: the client repair guard
152
- // and the server-side secret check must agree on what counts as a secret, or a
153
- // quoted/curly secret path the client repairs (e.g. service-account.json) slips
154
- // past the server check, which keys off the original tool-call args.
155
151
  const SENSITIVE_BASENAME_PATTERNS = [
156
152
  /^\.env(?:\..+)?$/i,
157
153
  /^\.?npmrc$/i,
@@ -39,11 +39,6 @@ export function parseArgs(argv) {
39
39
  usage = true;
40
40
  continue;
41
41
  }
42
- // An unrecognized dashed token is a mistyped flag, not prompt text. Without
43
- // an auth subcommand (whose flags are parsed separately) it would otherwise
44
- // be swept into the prompt and silently start a billable session. Flag the
45
- // first one so the caller can fail fast instead. Quoted prompts are a single
46
- // argv entry with spaces, so they never look like a bare option here.
47
42
  if (command === null && unknownOption === null && /^-/.test(arg)) {
48
43
  unknownOption = arg;
49
44
  continue;
@@ -1,10 +1,4 @@
1
- // Dependency-free ANSI styler with a chalk-compatible surface, imported as
2
- // `chalk` at call sites. Color gating (NO_COLOR / FORCE_COLOR / non-TTY) lives
3
- // in colorEnabled() below.
4
1
  const STYLE_NAMES = ['bold', 'dim', 'red', 'green', 'yellow', 'cyan'];
5
- // SGR open/close codes. Bold and dim share the 22 reset; colors share 39, so a
6
- // nested inner style restores exactly its own attribute without clearing the
7
- // outer one.
8
2
  const OPEN = {
9
3
  bold: '\x1b[1m',
10
4
  dim: '\x1b[2m',
@@ -33,8 +27,6 @@ function colorEnabled() {
33
27
  function applyStyle(name, text) {
34
28
  const open = OPEN[name];
35
29
  const close = CLOSE[name];
36
- // Re-open this style after any inner close of the same code, so a nested
37
- // style (e.g. chalk.red(`a ${chalk.bold('b')} c`)) doesn't terminate it early.
38
30
  const body = text.includes(close) ? text.split(close).join(close + open) : text;
39
31
  return open + body + close;
40
32
  }
@@ -43,7 +35,6 @@ function createStyler(styles) {
43
35
  const value = String(text);
44
36
  if (!colorEnabled() || styles.length === 0)
45
37
  return value;
46
- // Apply right-to-left so the first style in the chain is outermost.
47
38
  return styles.reduceRight((acc, name) => applyStyle(name, acc), value);
48
39
  });
49
40
  for (const name of STYLE_NAMES) {
@@ -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,93 @@
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
+ const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
7
+ const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
8
+ const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
9
+ `|'([^']*\\.${EXT})'` +
10
+ `|file://(\\S*\\.${EXT})` +
11
+ `|(${BARE_PATH})`, 'gi');
12
+ function detectImagePaths(input, cwd) {
13
+ const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
14
+ const rawsByPath = new Map();
15
+ let match;
16
+ while ((match = regex.exec(input)) !== null) {
17
+ const raw = match[0];
18
+ if (match[4] != null && raw.includes('://'))
19
+ continue;
20
+ let inner;
21
+ if (match[1] != null || match[2] != null) {
22
+ const quoted = (match[1] ?? match[2]);
23
+ if (/^file:\/\//i.test(quoted)) {
24
+ try {
25
+ inner = fileURLToPath(quoted);
26
+ }
27
+ catch {
28
+ continue;
29
+ }
30
+ }
31
+ else {
32
+ inner = quoted;
33
+ }
34
+ }
35
+ else if (match[3] != null) {
36
+ try {
37
+ inner = fileURLToPath(raw);
38
+ }
39
+ catch {
40
+ try {
41
+ inner = decodeURIComponent(match[3]);
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ }
47
+ }
48
+ else {
49
+ inner = match[4].replace(/\\ /g, ' ');
50
+ }
51
+ const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
52
+ const existing = rawsByPath.get(resolvedPath);
53
+ if (existing) {
54
+ existing.push(raw);
55
+ }
56
+ else {
57
+ rawsByPath.set(resolvedPath, [raw]);
58
+ }
59
+ }
60
+ return rawsByPath;
61
+ }
62
+ export function autoAttachImages(input, cwd, existing = []) {
63
+ const max = 2;
64
+ const rawsByPath = detectImagePaths(input, cwd);
65
+ let sanitizedInput = input;
66
+ const attachments = [];
67
+ const errors = [];
68
+ const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
69
+ for (const [resolvedPath, rawForms] of rawsByPath) {
70
+ if (existing.length + attachments.length >= max)
71
+ break;
72
+ if (!existsSync(resolvedPath))
73
+ continue;
74
+ try {
75
+ const loaded = loadImageFromFile(resolvedPath);
76
+ const idx = maxExistingIndex + attachments.length + 1;
77
+ attachments.push({
78
+ index: idx,
79
+ mimeType: loaded.mimeType,
80
+ base64Data: loaded.base64Data,
81
+ source: 'file',
82
+ filePath: resolvedPath,
83
+ });
84
+ for (const raw of rawForms) {
85
+ sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
86
+ }
87
+ }
88
+ catch (err) {
89
+ errors.push(err.message);
90
+ }
91
+ }
92
+ return { sanitizedInput, attachments, errors };
93
+ }
@@ -8,10 +8,6 @@ import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProj
8
8
  import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
9
9
  const requireFromHere = createRequire(import.meta.url);
10
10
  let nodePtyCache;
11
- // @lydell/node-pty ships its native binding as platform-specific optional
12
- // packages. If none is installed for this OS/arch the require throws; we cache
13
- // the failure and fall back to the non-interactive child_process path. A pty is
14
- // only needed to answer interactive sudo prompts.
15
11
  function loadNodePty() {
16
12
  if (nodePtyCache !== undefined)
17
13
  return nodePtyCache;
@@ -797,9 +793,6 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
797
793
  if (nodePty) {
798
794
  return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty);
799
795
  }
800
- // No pty binding installed for this platform — fall through to the
801
- // non-interactive spawn path. The command still runs; an interactive sudo
802
- // prompt simply can't be answered here.
803
796
  }
804
797
  return new Promise((resolve) => {
805
798
  let stdout = '';
@@ -1,9 +1,5 @@
1
1
  import chalk from './colors.js';
2
2
  import { getCliVersion, getPlatformTag } from './version.js';
3
- // The bound keys (Enter/Esc/Ctrl+C/Tab/arrows) are identical across platforms in
4
- // a terminal. The one thing that genuinely differs is the terminal's paste
5
- // shortcut, so surface the one for the host OS (right-click paste works
6
- // everywhere regardless).
7
3
  function pasteShortcutForPlatform() {
8
4
  switch (process.platform) {
9
5
  case 'darwin':
@@ -104,7 +100,6 @@ const HELP_MARKDOWN = [
104
100
  ' message — there is no client-side debug mode by design.',
105
101
  ].join('\n');
106
102
  export function formatAboutCard() {
107
- // Fenced so the column alignment survives terminal markdown rendering.
108
103
  return [
109
104
  '```',
110
105
  'TheGitAI',
@@ -162,7 +162,6 @@ export function writeProjectFile(rootDir, filePath, content) {
162
162
  }
163
163
  }
164
164
  catch {
165
- // If we can't read it for some reason, proceed with write
166
165
  }
167
166
  }
168
167
  mkdirSync(path.dirname(absPath), { recursive: true });
@@ -179,7 +178,6 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
179
178
  }
180
179
  }
181
180
  catch {
182
- // If we can't read it for some reason, proceed with write
183
181
  }
184
182
  }
185
183
  mkdirSync(path.dirname(absPath), { recursive: true });
@@ -42,12 +42,6 @@ function isFallbackIgnoredFile(relPath, fileName) {
42
42
  return true;
43
43
  return FALLBACK_LOCKFILES.has(fileName);
44
44
  }
45
- // Local stand-in for the previous glob('**/*', { nodir: true, dot: false,
46
- // ignore: FALLBACK_IGNORE }) call, used only when `git ls-files` fails (e.g. a
47
- // non-git directory). Walks the tree depth-first, skips dotfiles and
48
- // dot-directories (glob's dot: false), prunes ignored artifact dirs, and drops
49
- // lockfiles plus sensitive/ignored paths — reproducing the old fallback without
50
- // the glob dependency.
51
45
  function walkProjectFilesFallback(rootDir, limit) {
52
46
  const results = [];
53
47
  const visit = (relDir) => {
@@ -70,9 +64,6 @@ function walkProjectFilesFallback(rootDir, limit) {
70
64
  continue;
71
65
  const relPath = relDir ? `${relDir}/${name}` : name;
72
66
  if (entry.isDirectory()) {
73
- // Only the prefix-based artifact rule is safe to prune a directory by;
74
- // the sensitive-basename check must stay at the file level so a dir
75
- // merely named e.g. `secret` doesn't hide non-sensitive files under it.
76
67
  if (ALWAYS_IGNORE_DIRS.has(name) || shouldIgnoreArtifactPath(relPath)) {
77
68
  continue;
78
69
  }
@@ -6,11 +6,7 @@ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
6
6
  const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
7
7
  const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
8
8
  const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
9
- // Password embedded in a connection-string URL, e.g.
10
- // `postgresql://user:PASS@host`. Redacted from shell output so secrets in
11
- // commands like `cat .env` do not leak into history or telemetry.
12
9
  const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
13
- /** Redact only userinfo passwords in connection-string URLs (zero false positives). */
14
10
  export function redactConnectionStringCredentials(text) {
15
11
  return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
16
12
  }
@@ -72,12 +68,6 @@ export function isDotenvLikePath(value) {
72
68
  const base = path.posix.basename(text.replace(/\\/g, '/'));
73
69
  return DOTENV_BASENAME_PATTERN.test(base);
74
70
  }
75
- /**
76
- * True only for a clean dotenv file we can safely show with keys visible and
77
- * values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
78
- * is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
79
- * raw secret) returns false so the caller keeps the opaque blackout instead.
80
- */
81
71
  export function looksLikeEditableDotenv(content) {
82
72
  PEM_BLOCK_PATTERN.lastIndex = 0;
83
73
  if (PEM_BLOCK_PATTERN.test(content))
@@ -454,17 +454,6 @@ export function resolveRedactionTokens(state, text, filePath, hash) {
454
454
  }
455
455
  const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
456
456
  const DOTENV_COMMENT_PATTERN = /^(\s*#\s*)(\S.*)$/;
457
- /**
458
- * Redact a dotenv file's values while leaving keys visible. Every assignment's
459
- * value is replaced with a stable, reversible token so the agent can see the
460
- * file's structure and edit it (remove or replace lines) without ever seeing a
461
- * secret value; `resolveRedactionTokens` swaps the real values back on write.
462
- * Comment bodies are tokenized too, because developers routinely leave
463
- * commented-out credentials in dotenv files and those must not leak where the
464
- * opaque preview would have hidden them. Callers must confirm the content is
465
- * clean dotenv (`looksLikeEditableDotenv`) first so the only non-assignment
466
- * lines reaching here are blanks and comments.
467
- */
468
457
  export function redactDotenvWithStableTokens(state, content, filePath, hash) {
469
458
  const tokens = [];
470
459
  const redactedLines = content.split('\n').map((line) => {
@@ -490,14 +479,6 @@ export function redactDotenvWithStableTokens(state, content, filePath, hash) {
490
479
  });
491
480
  return { content: redactedLines.join('\n'), tokens };
492
481
  }
493
- /**
494
- * The redaction-token registry is capped at `MAX_REDACTION_TOKENS`; a read that
495
- * emits more tokens than that would evict its own oldest tokens, leaving
496
- * `[REDACTED:n]` markers in the preview that `write_file`/`str_replace` can no
497
- * longer resolve (silently writing the literal token back). So a dotenv file
498
- * with more tokenizable lines than the budget must not use the editable preview
499
- * — the caller falls back to the opaque blackout instead.
500
- */
501
482
  export function dotenvFitsRedactionBudget(content) {
502
483
  let count = 0;
503
484
  for (const line of content.split('\n')) {
@@ -122,7 +122,6 @@ function loadAllSnapshots(rootDir, env = process.env) {
122
122
  snapshots.push(loadSnapshotFile(filePath, rootDir));
123
123
  }
124
124
  catch {
125
- // Skip corrupted snapshots silently — customers have no actionable debug path here.
126
125
  }
127
126
  }
128
127
  return snapshots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -46,10 +46,6 @@ function getEditToolFilePath(call) {
46
46
  }
47
47
  return '';
48
48
  }
49
- // replace_document_text repairs its source filePath but writes a separate
50
- // outputPath verbatim (a write target must not be fold-matched onto a different
51
- // existing file). When an outputPath is given, the snapshot path it returns is
52
- // that raw output, so it must not be repaired.
53
49
  function editToolWritesSeparateOutput(call) {
54
50
  if (call.name !== 'replace_document_text')
55
51
  return false;
@@ -131,13 +127,6 @@ export async function executeLocalToolCall(toolContext, session, call) {
131
127
  session.onToolEvent?.({ call, result });
132
128
  return result;
133
129
  }
134
- // Snapshot the real file the edit tool will touch. Tools that repair their
135
- // path internally (str_replace/patch_file/replace_document_text) must be
136
- // snapshotted against the repaired path, or the pre-edit snapshot targets
137
- // the unrepaired path and the edit is journaled as a `create` and undone by
138
- // deleting the user's file. write_file/delete_file consume the raw path, so
139
- // repairing their snapshot would instead journal a phantom edit of a
140
- // different file — keep them on the raw path.
141
130
  const rawEditFilePath = isEditToolName(call.name)
142
131
  ? getEditToolFilePath(call)
143
132
  : '';
@@ -1,9 +1,6 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isSensitiveProjectPath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
4
- // "File not found" recovery hint: when a model mistypes a filename (most
5
- // often Unicode punctuation — a straight ' for a curly ’ — or a small typo),
6
- // suggest the closest real file from the same directory.
7
4
  function foldName(name) {
8
5
  return name
9
6
  .normalize('NFC')
@@ -46,9 +43,6 @@ export function suggestClosestPath(rootDir, missingPath) {
46
43
  let best = null;
47
44
  let bestDistance = Number.POSITIVE_INFINITY;
48
45
  for (const candidate of candidates) {
49
- // Never suggest a file the caller would refuse to read/write directly:
50
- // probing a near-miss like `.enx` or `credential.docx` must not leak the
51
- // existence of `.env`/credentials through the recovery hint.
52
46
  const candidateRelative = path.relative(rootDir, path.join(directory, candidate));
53
47
  if (isSensitiveProjectPath(candidateRelative))
54
48
  continue;
@@ -64,10 +58,6 @@ export function suggestClosestPath(rootDir, missingPath) {
64
58
  const relative = path.relative(rootDir, suggested);
65
59
  return relative && !relative.startsWith('..') ? relative : suggested;
66
60
  }
67
- // Fold only the punctuation/whitespace a model routinely alters when it echoes
68
- // a filename — a curly apostrophe ’ flattened to a straight ', smart double
69
- // quotes, and a non-breaking space — WITHOUT touching case, so a path is only
70
- // auto-corrected when nothing but this punctuation differs from a real file.
71
61
  function foldPunctuation(name) {
72
62
  return name
73
63
  .normalize('NFC')
@@ -75,9 +65,6 @@ function foldPunctuation(name) {
75
65
  .replace(/[“”]/g, '"')
76
66
  .replace(/ /g, ' ');
77
67
  }
78
- // Strip ONE matched pair of surrounding quotes. A path pasted from a file
79
- // manager's "Copy as path" or dragged into a terminal arrives wrapped in
80
- // '…' / "…", and that wrapping is captured verbatim as part of the filename.
81
68
  function stripSurroundingQuotes(p) {
82
69
  if (p.length >= 2) {
83
70
  const first = p[0];
@@ -88,9 +75,6 @@ function stripSurroundingQuotes(p) {
88
75
  }
89
76
  return p;
90
77
  }
91
- // A single backslash is a legal filename byte on POSIX, but models routinely
92
- // double it (it is JSON's escape character) when echoing a name, turning
93
- // `back\slash.js` into `back\\slash.js`. Collapse doubled backslashes to one.
94
78
  function collapseDoubledBackslashes(p) {
95
79
  return p.replace(/\\\\/g, '\\');
96
80
  }
@@ -105,36 +89,17 @@ function existsAgainst(rootDir, p) {
105
89
  return false;
106
90
  }
107
91
  }
108
- // Repair must never resolve a protected file (a secret like `.env`/credentials).
109
- // Repair only runs when the literal path is missing, so without this a quoted or
110
- // curly-flattened secret path — which used to fail as not-found — would be
111
- // silently resolved to the real secret, bypassing the redaction that keys off
112
- // the original tool-call args (e.g. read_document, str_replace, patch_file).
113
92
  function isProtectedRepairTarget(rootDir, candidate) {
114
93
  const rel = path.relative(rootDir, resolveAgainst(rootDir, candidate));
115
94
  const projectPath = rel && !rel.startsWith('..') ? rel : candidate;
116
95
  return (isSensitiveProjectPath(projectPath) ||
117
96
  (rel !== '' && !rel.startsWith('..') && shouldIgnoreArtifactPath(rel)));
118
97
  }
119
- // Edit tools that call repairFilePath on their path argument internally. The
120
- // executor repairs the pre-edit snapshot path only for these, so its snapshot
121
- // targets the same file the tool writes; write_file/delete_file consume the raw
122
- // path, so their snapshot must too.
123
98
  export const PATH_REPAIRING_EDIT_TOOLS = new Set([
124
99
  'str_replace',
125
100
  'patch_file',
126
101
  'replace_document_text',
127
102
  ]);
128
- // Repair a model/user-supplied path to a real on-disk file WITHOUT changing
129
- // intent, for tools that act on a file expected to already exist. Literal
130
- // first: any path that already resolves — including one that legitimately
131
- // contains quotes or backslashes — is returned untouched. Only when the path
132
- // does not resolve do we try safe de-manglings: strip surrounding quotes,
133
- // collapse doubled backslashes, and finally match a directory entry that
134
- // differs only by foldable punctuation (the "model flattened a curly ’ to a
135
- // straight '" case, which no transform of the input can reproduce). Returns the
136
- // input unchanged when nothing better exists, so the caller's normal not-found
137
- // handling (and its recovery hint) still fires.
138
103
  export function repairFilePath(rootDir, raw) {
139
104
  if (!raw || existsAgainst(rootDir, raw))
140
105
  return raw;
@@ -164,12 +129,10 @@ export function repairFilePath(rootDir, raw) {
164
129
  }
165
130
  const matches = entries.filter((entry) => foldPunctuation(entry) === wanted);
166
131
  if (matches.length !== 1)
167
- return raw; // none, or ambiguous — never guess
132
+ return raw;
168
133
  const matchedAbs = path.join(directory, matches[0]);
169
134
  const matchedRel = path.relative(rootDir, matchedAbs);
170
135
  const matched = path.isAbsolute(dequoted) ? matchedAbs : matchedRel || matchedAbs;
171
- // Never auto-resolve into a protected file: a fold-match that lands on `.env`
172
- // would confirm its existence to the model and bypass redaction.
173
136
  if (isProtectedRepairTarget(rootDir, matched))
174
137
  return raw;
175
138
  return matched;
@@ -68,10 +68,6 @@ export async function readFile(context, args) {
68
68
  }
69
69
  }
70
70
  const previewPath = projectPath ?? filePath;
71
- // A clean dotenv file is shown with keys visible and values tokenized so the
72
- // agent can still edit it (str_replace/write_file round-trip the tokens) and
73
- // read coverage is recorded. Any other secret file — PEM, JSON credentials,
74
- // or a dotenv with a stray non-assignment line — keeps the opaque blackout.
75
71
  const editableDotenv = Boolean(projectPath) &&
76
72
  Boolean(safety) &&
77
73
  isDotenvLikePath(previewPath) &&
@@ -95,10 +95,6 @@ export async function replaceDocumentText(context, args) {
95
95
  failureCategory: 'invalid_argument',
96
96
  };
97
97
  }
98
- // outputPath is a write target, not an existing input, so it must NOT be
99
- // path-repaired: fold-match could redirect a "create Review '24.docx" onto an
100
- // existing Review ’24.docx and overwrite it. The executor mirrors this by not
101
- // repairing the snapshot path when an outputPath is present.
102
98
  const outputRaw = String(args.outputPath ?? args.output_path ?? '').trim();
103
99
  const targetPath = outputRaw
104
100
  ? relativeEditablePath(context.rootDir, outputRaw)
@@ -153,9 +149,6 @@ export async function replaceDocumentText(context, args) {
153
149
  failureCategory: serverResult.failureCategory ?? 'external_service',
154
150
  };
155
151
  }
156
- // Validate-only: report per-replacement match info without touching the file.
157
- // changed:false marks it non-mutating so the agent loop does not count a
158
- // dry-run as an applied edit.
159
152
  if (validateOnly) {
160
153
  return {
161
154
  ok: true,
@@ -166,8 +159,6 @@ export async function replaceDocumentText(context, args) {
166
159
  results: serverResult.results,
167
160
  };
168
161
  }
169
- // No replacement matched: nothing was written. Surface per-item reasons so
170
- // the model can correct and resend only the failing entries.
171
162
  const replacementCount = Number(serverResult.replacementCount ?? 0);
172
163
  if (replacementCount === 0) {
173
164
  const failures = Array.isArray(serverResult.replacements)
@@ -224,9 +215,6 @@ export async function replaceDocumentText(context, args) {
224
215
  failedCount: serverResult.failedCount,
225
216
  replacements: serverResult.replacements,
226
217
  bytesWritten: nextData.length,
227
- // A partial batch still wrote the matched entries (changed:true above), but
228
- // the loop must reflect and repair the missed entries — needsRepair forces
229
- // that without losing credit for the applied edits.
230
218
  ...(failedCount > 0
231
219
  ? {
232
220
  needsRepair: true,
@@ -67,8 +67,6 @@ export async function runShellCommand(context, args) {
67
67
  if (repoSync.added || repoSync.modified || repoSync.removed) {
68
68
  onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
69
69
  }
70
- // Redact connection-string passwords so shell output (e.g. `cat .env`,
71
- // `printenv`) cannot leak them into history or telemetry.
72
70
  let output = typeof result.output === 'string'
73
71
  ? redactConnectionStringCredentials(result.output)
74
72
  : result.output;
@@ -6,12 +6,6 @@ import { addSignatureForNode } from './extractors/index.js';
6
6
  import { getRepoMapLanguageForFile, } from './repo-map-languages.js';
7
7
  const require = createRequire(import.meta.url);
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
- // web-tree-sitter is vendored (see vendor/web-tree-sitter/NOTICE) so the
10
- // published package has zero runtime dependencies. Resolve the vendored CommonJS
11
- // runtime relative to this compiled file: dist/src/ -> dist/vendor in the
12
- // published layout, with the source tree as a dev fallback. The .cjs locates its
13
- // own web-tree-sitter.wasm next to itself via __dirname, so no locateFile
14
- // override is needed.
15
9
  function resolveVendoredTreeSitter() {
16
10
  const candidates = [
17
11
  path.resolve(__dirname, '..', 'vendor', 'web-tree-sitter', 'web-tree-sitter.cjs'),
@@ -998,8 +998,6 @@ export function getInputCommandToken(input) {
998
998
  return '';
999
999
  const firstSpaceIndex = trimmed.indexOf(' ');
1000
1000
  const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1001
- // A token with a second '/' is a filesystem path (e.g. /home/user/repo),
1002
- // not a slash command — no command contains a slash, so don't treat it as one.
1003
1001
  if (token.indexOf('/', 1) !== -1)
1004
1002
  return '';
1005
1003
  return token;
@@ -1012,9 +1010,6 @@ function shouldShowCommandPalette(state) {
1012
1010
  !state.resumePickerOpen &&
1013
1011
  trimmed.startsWith('/') &&
1014
1012
  !trimmed.includes(' ') &&
1015
- // A '/'-prefixed token with a second '/' is a filesystem path, not a
1016
- // command — getInputCommandToken returns '' for it, so the palette stays
1017
- // closed when a folder path is pasted.
1018
1013
  getInputCommandToken(trimmed) !== '');
1019
1014
  }
1020
1015
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
@@ -1405,9 +1400,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1405
1400
  activeTurnAbort?.abort();
1406
1401
  activeTurnAbort = null;
1407
1402
  cancelActiveCommand();
1408
- // Ctrl+C with a queued message: recall it into the composer for editing
1409
- // rather than auto-submitting it against the cancelled turn. (Esc with a
1410
- // queued message clears the slot in shell-input without cancelling.)
1411
1403
  const queued = store.getState().queuedMessage;
1412
1404
  const cancelledEntries = [
1413
1405
  ...takePendingTurnEntries(),
@@ -1811,8 +1803,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1811
1803
  if (!queued || store.getState().busy || exiting) {
1812
1804
  return;
1813
1805
  }
1814
- // Rehydrate attachments/chunks before submit: handleSubmit expands
1815
- // pastedChunks from the store and the turn picks up imageAttachments.
1816
1806
  store.update((current) => ({
1817
1807
  ...current,
1818
1808
  imageAttachments: queued.imageAttachments,
@@ -1839,9 +1829,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1839
1829
  });
1840
1830
  return;
1841
1831
  }
1842
- // Snapshot the raw (placeholder) body plus chunks/images so recall and
1843
- // flush round-trip the collapsed paste + attachments. Hold at most one;
1844
- // a second enqueue replaces the slot.
1845
1832
  const pending = store.getState();
1846
1833
  const snapshot = {
1847
1834
  body: String(rawInput ?? ''),
@@ -2094,8 +2081,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2094
2081
  appendError(error.message);
2095
2082
  }
2096
2083
  await remountTui();
2097
- // A cancelled turn never auto-submits the queue (Ctrl+C recalls it via
2098
- // cancelActiveTurn); only a genuine error flushes a pending message.
2099
2084
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2100
2085
  await flushQueuedMessage();
2101
2086
  }
@@ -91,14 +91,11 @@ function normalizeChildMessage(raw) {
91
91
  export function resolveTuiBinaryPath() {
92
92
  const binaryName = process.platform === 'win32' ? 'thegitai-tui.exe' : 'thegitai-tui';
93
93
  const platformPackage = `@thegitai/tui-${process.platform}-${process.arch}`;
94
- // 1) Published per-platform optional dependency (the installed-from-npm path).
95
94
  try {
96
95
  return requireFromHere.resolve(`${platformPackage}/${binaryName}`);
97
96
  }
98
97
  catch {
99
- // Not installed (unsupported platform yet, or local dev) — fall through.
100
98
  }
101
- // 2) Local dev build: `npm run build:tui` populates the workspace bin/.
102
99
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
103
100
  const devCandidates = [
104
101
  path.join(moduleDir, '../../../bin', binaryName),
@@ -137,7 +134,6 @@ export function createRatatuiBridge() {
137
134
  }
138
135
  }
139
136
  catch {
140
- // ignore malformed protocol lines
141
137
  }
142
138
  });
143
139
  child.on('exit', () => {
@@ -131,9 +131,6 @@ function diffLinePrefix(kind) {
131
131
  return ' ';
132
132
  }
133
133
  }
134
- // Fit a single diff line to the available terminal width with a clean ellipsis.
135
- // Unlike the general-purpose truncate(), this never appends a word like
136
- // "(truncated)" — that wording belongs on tool output, not on-screen diff rows.
137
134
  function fitDiffLine(content, maxWidth) {
138
135
  if (maxWidth <= 0)
139
136
  return '';
@@ -178,8 +175,6 @@ function getInputCommandToken(input) {
178
175
  const trimmed = String(input ?? '').trimStart();
179
176
  const match = trimmed.match(/^\/[^\s]*/);
180
177
  const token = match?.[0] ?? '';
181
- // A token with a second '/' is a filesystem path (e.g. /home/user/repo),
182
- // not a slash command — no command contains a slash, so don't treat it as one.
183
178
  if (token.indexOf('/', 1) !== -1)
184
179
  return '';
185
180
  return token;
@@ -689,9 +684,6 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
689
684
  sections.push({ kind: 'live', lines: liveLines });
690
685
  }
691
686
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
692
- // Composer stays visible while busy unless a blocking overlay is active. When
693
- // one message is already queued, the queued chip is shown here in place of the
694
- // input box (only one message is ever held) so there is no empty prompt.
695
687
  if (!state.resumePickerOpen && !state.modelPickerOpen && !overlayActive) {
696
688
  const composerLines = [];
697
689
  if (state.queuedMessage) {
@@ -275,8 +275,6 @@ export function handleShellKeyEvent(store, handlers, event) {
275
275
  }
276
276
  if (key.escape) {
277
277
  if (state.busy) {
278
- // With a queued message, Esc clears the queue only (turn keeps running).
279
- // With nothing queued, Esc cancels the turn (Ctrl+C also cancels).
280
278
  if (state.queuedMessage) {
281
279
  handlers.onLiveFrameShapeChange();
282
280
  store.update((current) => ({ ...current, queuedMessage: null }));
@@ -317,8 +315,6 @@ export function handleShellKeyEvent(store, handlers, event) {
317
315
  return;
318
316
  }
319
317
  if (key.upArrow) {
320
- // While busy with an empty composer, Up recalls and dequeues the queued
321
- // message for editing; otherwise it walks prompt history as usual.
322
318
  if (state.busy && state.input.trim() === '' && state.queuedMessage) {
323
319
  handlers.onLiveFrameShapeChange();
324
320
  store.update((current) => {
@@ -3,11 +3,6 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  const PACKAGE_NAME = '@thegitai/cli';
5
5
  const UNKNOWN_VERSION = '0.0.0';
6
- // Resolve the package version at runtime by walking up from this module to the
7
- // nearest package.json named @thegitai/cli. This works in both layouts: the
8
- // compiled binary (dist/bin/ai.js → ../../package.json) and the source tree run
9
- // under tsx in tests (src/version.ts → ../package.json). The name guard avoids
10
- // picking up an unrelated manifest if the file is ever nested elsewhere.
11
6
  export function getCliVersion() {
12
7
  let dir = path.dirname(fileURLToPath(import.meta.url));
13
8
  for (let depth = 0; depth < 6; depth++) {
@@ -18,7 +13,6 @@ export function getCliVersion() {
18
13
  }
19
14
  }
20
15
  catch {
21
- // No package.json at this level (or unreadable); keep walking up.
22
16
  }
23
17
  const parent = path.dirname(dir);
24
18
  if (parent === dir)
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.15",
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.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",
32
32
  "@vscode/ripgrep": "1.18.0"
33
33
  },
34
34
  "publishConfig": {