@thegitai/cli 1.0.0-beta.1 → 1.0.0-beta.10

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.
Files changed (45) hide show
  1. package/README.md +4 -3
  2. package/dist/bin/ai.js +18 -61
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/api/auth.js +4 -2
  5. package/dist/src/api/browser-login.js +10 -28
  6. package/dist/src/api/chat.js +20 -9
  7. package/dist/src/api/http.js +32 -3
  8. package/dist/src/api/models.js +4 -2
  9. package/dist/src/artifact-policy.js +9 -0
  10. package/dist/src/cli-args.js +65 -0
  11. package/dist/src/client-environment.js +127 -0
  12. package/dist/src/colors.js +59 -0
  13. package/dist/src/core/clipboard.js +56 -0
  14. package/dist/src/edit-journal.js +39 -6
  15. package/dist/src/executor.js +28 -5
  16. package/dist/src/help-text.js +15 -1
  17. package/dist/src/markdown-renderer.js +1 -1
  18. package/dist/src/patcher.js +18 -1
  19. package/dist/src/scanner.js +67 -17
  20. package/dist/src/session-safety.js +64 -12
  21. package/dist/src/tool-executor.js +8 -0
  22. package/dist/src/tools/delete-file.js +1 -1
  23. package/dist/src/tools/index.js +2 -0
  24. package/dist/src/tools/patch-file.js +14 -1
  25. package/dist/src/tools/path-suggest.js +66 -0
  26. package/dist/src/tools/read-document.js +14 -3
  27. package/dist/src/tools/read-file.js +9 -0
  28. package/dist/src/tools/replace-document-text.js +242 -0
  29. package/dist/src/tools/restore-checkpoint.js +1 -1
  30. package/dist/src/tools/run-command.js +1 -1
  31. package/dist/src/tools/run-node-script.js +1 -1
  32. package/dist/src/tools/str-replace.js +14 -1
  33. package/dist/src/tools/undo-edit.js +7 -5
  34. package/dist/src/tools/write-file.js +14 -1
  35. package/dist/src/tree-sitter-runtime.js +14 -1
  36. package/dist/src/ui/repl.js +13 -1
  37. package/dist/src/ui/tui/bridge.js +2 -2
  38. package/dist/src/ui/tui/build-frame.js +18 -2
  39. package/dist/src/ui/tui/shell-input.js +9 -1
  40. package/dist/src/version.js +35 -0
  41. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  42. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  43. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  44. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  45. package/package.json +15 -16
@@ -0,0 +1,242 @@
1
+ import chalk from '../colors.js';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
5
+ import { readCliAuthConfig } from '../api/auth.js';
6
+ import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
7
+ import { suggestClosestPath } from './path-suggest.js';
8
+ import { isTuiMode } from '../runtime-mode.js';
9
+ function normalizeReplacements(value) {
10
+ if (!Array.isArray(value))
11
+ return [];
12
+ return value
13
+ .map((item) => {
14
+ if (!item || typeof item !== 'object')
15
+ return null;
16
+ const entry = item;
17
+ const oldText = String(entry.oldText ?? entry.old_text ?? '');
18
+ const newText = String(entry.newText ?? entry.new_text ?? '');
19
+ if (!oldText)
20
+ return null;
21
+ return { oldText, newText };
22
+ })
23
+ .filter(Boolean);
24
+ }
25
+ function relativeEditablePath(rootDir, rawPath) {
26
+ const resolvedPath = path.isAbsolute(rawPath)
27
+ ? rawPath
28
+ : path.resolve(rootDir, rawPath);
29
+ const projectPath = normalizeProjectRelativePath(rootDir, resolvedPath);
30
+ if (!projectPath || isSensitiveProjectPath(projectPath))
31
+ return null;
32
+ return projectPath.split(path.sep).join('/');
33
+ }
34
+ function renderPreview(filePath, preview) {
35
+ if (isTuiMode())
36
+ return;
37
+ console.log(chalk.bold(`\n Document text replacement preview for ${filePath}:`));
38
+ for (const line of preview.split('\n')) {
39
+ if (line.startsWith('+')) {
40
+ console.log(chalk.green(line));
41
+ }
42
+ else if (line.startsWith('-')) {
43
+ console.log(chalk.red(line));
44
+ }
45
+ else if (line.startsWith('@@')) {
46
+ console.log(chalk.cyan(line));
47
+ }
48
+ else {
49
+ console.log(chalk.dim(line));
50
+ }
51
+ }
52
+ console.log();
53
+ }
54
+ async function replaceDocumentTextOnServer(config, fileName, fileData, replacements, replaceAll, validate) {
55
+ const response = await globalThis.fetch(`${config.serverUrl.replace(/\/+$/, '')}/v1/document/replace-text`, {
56
+ method: 'POST',
57
+ headers: {
58
+ authorization: `Bearer ${config.token}`,
59
+ 'content-type': 'application/json',
60
+ },
61
+ body: JSON.stringify({
62
+ fileName,
63
+ fileData: fileData.toString('base64'),
64
+ replacements,
65
+ replaceAll,
66
+ validate,
67
+ }),
68
+ });
69
+ const data = await response.json().catch(() => null);
70
+ if (!response.ok) {
71
+ return {
72
+ ok: false,
73
+ error: `Server document edit failed: ${data?.error?.message ?? response.status}`,
74
+ };
75
+ }
76
+ return data;
77
+ }
78
+ export async function replaceDocumentText(context, args) {
79
+ const sourceRaw = String(args.filePath ?? args.file_path ?? '').trim();
80
+ if (!sourceRaw) {
81
+ return { ok: false, error: 'filePath is required' };
82
+ }
83
+ const sourcePath = relativeEditablePath(context.rootDir, sourceRaw);
84
+ if (!sourcePath) {
85
+ return {
86
+ ok: false,
87
+ error: 'replace_document_text can only edit permitted files inside the project root.',
88
+ failureCategory: 'permission_denied',
89
+ };
90
+ }
91
+ if (path.extname(sourcePath).toLowerCase() !== '.docx') {
92
+ return {
93
+ ok: false,
94
+ error: 'replace_document_text only supports .docx files.',
95
+ failureCategory: 'invalid_argument',
96
+ };
97
+ }
98
+ const outputRaw = String(args.outputPath ?? args.output_path ?? '').trim();
99
+ const targetPath = outputRaw
100
+ ? relativeEditablePath(context.rootDir, outputRaw)
101
+ : sourcePath;
102
+ if (!targetPath) {
103
+ return {
104
+ ok: false,
105
+ error: 'outputPath must be inside the project root.',
106
+ failureCategory: 'permission_denied',
107
+ };
108
+ }
109
+ if (path.extname(targetPath).toLowerCase() !== '.docx') {
110
+ return {
111
+ ok: false,
112
+ error: 'outputPath must end with .docx.',
113
+ failureCategory: 'invalid_argument',
114
+ };
115
+ }
116
+ const replacements = normalizeReplacements(args.replacements);
117
+ if (!replacements.length) {
118
+ return {
119
+ ok: false,
120
+ error: 'replacements must include at least one { oldText, newText } item.',
121
+ failureCategory: 'missing_required_argument',
122
+ };
123
+ }
124
+ const authConfig = readCliAuthConfig(context.env);
125
+ if (!authConfig) {
126
+ return {
127
+ ok: false,
128
+ error: 'Document editing requires a server connection. Please log in first.',
129
+ };
130
+ }
131
+ const sourceAbsPath = resolveProjectPath(context.rootDir, sourcePath);
132
+ if (!existsSync(sourceAbsPath)) {
133
+ const suggestion = suggestClosestPath(context.rootDir, sourceAbsPath);
134
+ return {
135
+ ok: false,
136
+ filePath: sourcePath,
137
+ error: suggestion
138
+ ? `File does not exist: ${sourcePath}. Did you mean "${suggestion}"? Note the exact punctuation (e.g. curly apostrophe ’ vs straight ').`
139
+ : `File does not exist: ${sourcePath}`,
140
+ failureCategory: 'not_found',
141
+ };
142
+ }
143
+ const validateOnly = args.validate === true || args.dryRun === true;
144
+ const serverResult = await replaceDocumentTextOnServer(authConfig, path.basename(sourcePath), readFileSync(sourceAbsPath), replacements, args.replaceAll === true || args.replace_all === true, validateOnly);
145
+ if (!serverResult.ok) {
146
+ return {
147
+ ...serverResult,
148
+ filePath: sourcePath,
149
+ failureCategory: serverResult.failureCategory ?? 'external_service',
150
+ };
151
+ }
152
+ // Validate-only: report per-replacement match info without touching the file.
153
+ // changed:false marks it non-mutating so the agent loop does not count a
154
+ // dry-run as an applied edit.
155
+ if (validateOnly) {
156
+ return {
157
+ ok: true,
158
+ validate: true,
159
+ changed: false,
160
+ filePath: sourcePath,
161
+ operation: 'replace_document_text',
162
+ results: serverResult.results,
163
+ };
164
+ }
165
+ // No replacement matched: nothing was written. Surface per-item reasons so
166
+ // the model can correct and resend only the failing entries.
167
+ const replacementCount = Number(serverResult.replacementCount ?? 0);
168
+ if (replacementCount === 0) {
169
+ const failures = Array.isArray(serverResult.replacements)
170
+ ? serverResult.replacements
171
+ .filter((item) => item && item.ok === false)
172
+ .map((item) => `- ${item.error ?? 'no match'}`)
173
+ : [];
174
+ return {
175
+ ok: false,
176
+ filePath: sourcePath,
177
+ operation: 'replace_document_text',
178
+ failureCategory: 'conflict',
179
+ error: `0 of ${serverResult.requestedCount ?? replacements.length} replacements applied; file unchanged.` +
180
+ (failures.length ? `\n${failures.join('\n')}` : ''),
181
+ replacements: serverResult.replacements,
182
+ };
183
+ }
184
+ const preview = String(serverResult.preview ?? '');
185
+ renderPreview(targetPath, preview);
186
+ if (!context.autoYes && context.confirmPatch) {
187
+ const confirmed = await context.confirmPatch(targetPath, preview);
188
+ if (!confirmed) {
189
+ if (!isTuiMode()) {
190
+ console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
191
+ }
192
+ return {
193
+ ok: false,
194
+ skipped: true,
195
+ filePath: targetPath,
196
+ error: 'User declined replace_document_text',
197
+ };
198
+ }
199
+ }
200
+ const fileData = String(serverResult.fileData ?? '');
201
+ const nextData = Buffer.from(fileData, 'base64');
202
+ const write = writeProjectFileBuffer(context.rootDir, targetPath, nextData);
203
+ const failedCount = Number(serverResult.failedCount ?? 0);
204
+ const appliedCount = Number(serverResult.appliedCount ?? replacementCount);
205
+ const requestedCount = Number(serverResult.requestedCount ?? replacements.length);
206
+ const partialFailures = Array.isArray(serverResult.replacements)
207
+ ? serverResult.replacements
208
+ .filter((item) => item && item.ok === false)
209
+ .map((item) => `- ${item.error ?? 'no match'}`)
210
+ : [];
211
+ return {
212
+ ok: true,
213
+ filePath: targetPath,
214
+ sourceFilePath: sourcePath,
215
+ changed: write.changed,
216
+ operation: 'replace_document_text',
217
+ replacementCount: serverResult.replacementCount,
218
+ requestedCount: serverResult.requestedCount,
219
+ appliedCount: serverResult.appliedCount,
220
+ failedCount: serverResult.failedCount,
221
+ replacements: serverResult.replacements,
222
+ bytesWritten: nextData.length,
223
+ // A partial batch still wrote the matched entries (changed:true above), but
224
+ // the loop must reflect and repair the missed entries — needsRepair forces
225
+ // that without losing credit for the applied edits.
226
+ ...(failedCount > 0
227
+ ? {
228
+ needsRepair: true,
229
+ failureCategory: 'conflict',
230
+ error: `${appliedCount} of ${requestedCount} replacements applied; ` +
231
+ `${failedCount} missed and still need to be fixed:\n` +
232
+ partialFailures.join('\n'),
233
+ failureDetails: {
234
+ category: 'conflict',
235
+ tool: 'replace_document_text',
236
+ action: 'Re-issue replace_document_text for the missed entries only, with corrected oldText. ' +
237
+ 'Copy the exact text from read_document (mind curly vs straight quotes), or pass validate:true to test a match first.',
238
+ },
239
+ }
240
+ : {}),
241
+ };
242
+ }
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import chalk from '../colors.js';
2
2
  import { isTuiMode } from '../runtime-mode.js';
3
3
  import { createPromptCheckpoint, restoreCheckpointFiles, } from '../session-safety.js';
4
4
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import chalk from '../colors.js';
2
2
  import { getBlockedCommandReason, runCommand, } from '../executor.js';
3
3
  import { syncIndexFromDisk } from '../project-index.js';
4
4
  import { isTuiMode } from '../runtime-mode.js';
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import chalk from '../colors.js';
2
2
  import { execFileSync, spawn } from 'node:child_process';
3
3
  import { syncIndexFromDisk } from '../project-index.js';
4
4
  import { isTuiMode } from '../runtime-mode.js';
@@ -1,10 +1,12 @@
1
- import chalk from 'chalk';
1
+ import chalk from '../colors.js';
2
+ import path from 'node:path';
2
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
3
4
  import { readProjectFile, writeProjectFile } from '../patcher.js';
4
5
  import { upsertIndexFile } from '../project-index.js';
5
6
  import { isTuiMode } from '../runtime-mode.js';
6
7
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
7
8
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
9
+ const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
8
10
  function countOccurrences(haystack, needle) {
9
11
  if (needle.length === 0)
10
12
  return 0;
@@ -92,6 +94,17 @@ export async function strReplace(context, args) {
92
94
  if (oldString.length === 0) {
93
95
  return { ok: false, error: 'old_string is required and must be non-empty' };
94
96
  }
97
+ const ext = path.extname(filePath).toLowerCase();
98
+ if (DOCUMENT_EXTENSIONS.has(ext)) {
99
+ return {
100
+ ok: false,
101
+ filePath,
102
+ error: ext === '.docx'
103
+ ? 'Use replace_document_text for .docx files.'
104
+ : `Use read_document for ${ext} files; text replacement is not supported.`,
105
+ failureCategory: 'invalid_argument',
106
+ };
107
+ }
95
108
  let originalContent;
96
109
  try {
97
110
  originalContent = readProjectFile(rootDir, filePath);
@@ -1,6 +1,6 @@
1
- import chalk from 'chalk';
2
- import { hashContent, readFileEditSnapshot, } from '../edit-journal.js';
3
- import { deleteProjectFile, writeProjectFile, } from '../patcher.js';
1
+ import chalk from '../colors.js';
2
+ import { hashStoredContent, readFileEditSnapshot, storedContentBuffer, } from '../edit-journal.js';
3
+ import { deleteProjectFile, writeProjectFile, writeProjectFileBuffer, } from '../patcher.js';
4
4
  import { removeIndexFile, upsertIndexFile, } from '../project-index.js';
5
5
  import { isTuiMode } from '../runtime-mode.js';
6
6
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
@@ -84,7 +84,7 @@ function validateUndoPlan(rootDir, records) {
84
84
  currentHash,
85
85
  };
86
86
  }
87
- const beforeContentHash = hashContent(record.beforeContent);
87
+ const beforeContentHash = hashStoredContent(record.beforeContent, record.beforeContentEncoding);
88
88
  if (beforeContentHash !== record.beforeHash) {
89
89
  return {
90
90
  ok: false,
@@ -110,7 +110,9 @@ async function applyUndo(context, record) {
110
110
  if (record.beforeContent === null) {
111
111
  throw new Error(`Cannot undo ${record.id}: the stored pre-edit snapshot is incomplete.`);
112
112
  }
113
- const { changed } = writeProjectFile(rootDir, record.filePath, record.beforeContent);
113
+ const { changed } = record.beforeContentEncoding === 'base64'
114
+ ? writeProjectFileBuffer(rootDir, record.filePath, storedContentBuffer(record.beforeContent, record.beforeContentEncoding))
115
+ : writeProjectFile(rootDir, record.filePath, record.beforeContent);
114
116
  if (changed) {
115
117
  await upsertIndexFile(projectIndex, record.filePath);
116
118
  }
@@ -1,10 +1,12 @@
1
- import chalk from 'chalk';
1
+ import chalk from '../colors.js';
2
+ import path from 'node:path';
2
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
3
4
  import { writeProjectFile } from '../patcher.js';
4
5
  import { upsertIndexFile } from '../project-index.js';
5
6
  import { isTuiMode } from '../runtime-mode.js';
6
7
  import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
7
8
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
9
+ const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
8
10
  export async function writeFile(context, args) {
9
11
  const { rootDir, projectIndex } = context;
10
12
  const filePath = String(args.filePath ?? '').trim();
@@ -12,6 +14,17 @@ export async function writeFile(context, args) {
12
14
  if (!filePath) {
13
15
  return { ok: false, error: 'filePath is required' };
14
16
  }
17
+ const ext = path.extname(filePath).toLowerCase();
18
+ if (DOCUMENT_EXTENSIONS.has(ext)) {
19
+ return {
20
+ ok: false,
21
+ filePath,
22
+ error: ext === '.docx'
23
+ ? 'Use replace_document_text for .docx files.'
24
+ : `Use read_document for ${ext} files; write_file is not supported.`,
25
+ failureCategory: 'invalid_argument',
26
+ };
27
+ }
15
28
  const coveragePath = normalizeProjectRelativePath(rootDir, filePath) ?? filePath;
16
29
  const currentHash = getCurrentFileHash(rootDir, filePath);
17
30
  if (currentHash !== null &&
@@ -5,8 +5,21 @@ import { fileURLToPath } from 'node:url';
5
5
  import { addSignatureForNode } from './extractors/index.js';
6
6
  import { getRepoMapLanguageForFile, } from './repo-map-languages.js';
7
7
  const require = createRequire(import.meta.url);
8
- const TreeSitter = require('web-tree-sitter');
9
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
+ function resolveVendoredTreeSitter() {
16
+ const candidates = [
17
+ path.resolve(__dirname, '..', 'vendor', 'web-tree-sitter', 'web-tree-sitter.cjs'),
18
+ path.resolve(__dirname, '..', '..', 'vendor', 'web-tree-sitter', 'web-tree-sitter.cjs'),
19
+ ];
20
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
21
+ }
22
+ const TreeSitter = require(resolveVendoredTreeSitter());
10
23
  let parserInitPromise = null;
11
24
  const parserCache = Object.create(null);
12
25
  const languageCache = Object.create(null);
@@ -11,7 +11,7 @@ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSes
11
11
  import { truncate } from '../utils.js';
12
12
  import { writeClipboardText } from '../core/clipboard.js';
13
13
  import { openUrl } from '../core/open-url.js';
14
- import { formatInteractiveHelpText } from '../help-text.js';
14
+ import { formatAboutCard, formatInteractiveHelpText } from '../help-text.js';
15
15
  import { expandPastedChunks, } from './paste-collapse.js';
16
16
  import { loadPromptHistory, MAX_PROMPT_HISTORY_ENTRIES, } from './prompt-history-store.js';
17
17
  const RESPONSE_STREAM_CHUNK_SIZE = 4;
@@ -86,6 +86,10 @@ export const SLASH_COMMANDS = [
86
86
  command: '/help',
87
87
  description: 'Show available chat commands',
88
88
  },
89
+ {
90
+ command: '/about',
91
+ description: 'Show version and platform info',
92
+ },
89
93
  {
90
94
  command: '/usage',
91
95
  description: 'Show account usage percentage and reset times',
@@ -1875,6 +1879,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1875
1879
  });
1876
1880
  return;
1877
1881
  }
1882
+ if (input === '/about') {
1883
+ appendStaticEntry({
1884
+ body: formatAboutCard(),
1885
+ kind: 'system',
1886
+ title: 'About',
1887
+ });
1888
+ return;
1889
+ }
1878
1890
  if (input === '/usage') {
1879
1891
  store.update((current) => ({
1880
1892
  ...current,
@@ -114,13 +114,13 @@ export function resolveTuiBinaryPath() {
114
114
  export function spawnTuiProcess(options = {}) {
115
115
  const binaryPath = resolveTuiBinaryPath();
116
116
  return spawn(binaryPath, [], {
117
- stdio: ['pipe', 'pipe', 'inherit'],
117
+ stdio: ['pipe', 'inherit', 'pipe'],
118
118
  env: { ...process.env, ...options.env },
119
119
  });
120
120
  }
121
121
  export function createRatatuiBridge() {
122
122
  const child = spawnTuiProcess();
123
- const rl = createInterface({ input: child.stdout });
123
+ const rl = createInterface({ input: child.stderr });
124
124
  let eventHandler = null;
125
125
  let closed = false;
126
126
  rl.on('line', (line) => {
@@ -131,6 +131,18 @@ 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
+ function fitDiffLine(content, maxWidth) {
138
+ if (maxWidth <= 0)
139
+ return '';
140
+ if (content.length <= maxWidth)
141
+ return content;
142
+ if (maxWidth === 1)
143
+ return '…';
144
+ return `${content.slice(0, maxWidth - 1)}…`;
145
+ }
134
146
  export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
135
147
  const trimmed = String(projectRoot ?? '').trim();
136
148
  if (!trimmed)
@@ -228,7 +240,7 @@ export function renderTranscriptEntryLines(entry, width) {
228
240
  if (entry.diffPreview) {
229
241
  lines.push(plainLine(` Added ${entry.diffPreview.added} line${entry.diffPreview.added === 1 ? '' : 's'}, removed ${entry.diffPreview.removed} line${entry.diffPreview.removed === 1 ? '' : 's'}`, { color: 'gray' }));
230
242
  for (const diffLine of entry.diffPreview.lines.slice(0, TRANSCRIPT_DIFF_PREVIEW_LINES)) {
231
- lines.push(line(span(`${diffLinePrefix(diffLine.kind)} `, { color: diffLineColor(diffLine.kind) }), span(truncate(diffLine.content || ' ', width - 4), {
243
+ lines.push(line(span(`${diffLinePrefix(diffLine.kind)} `, { color: diffLineColor(diffLine.kind) }), span(fitDiffLine(diffLine.content || ' ', width - 4), {
232
244
  color: diffLineColor(diffLine.kind),
233
245
  })));
234
246
  }
@@ -311,7 +323,11 @@ function composerFooterLines(state) {
311
323
  ? state.queuedMessage
312
324
  ? 'Enter re-queues • ↑ edit queued • Esc cancels queued'
313
325
  : 'Enter queues • Esc / Ctrl+C cancel turn'
314
- : 'Enter sends • Shift+Tab mode • Esc cancel turn • Ctrl+C quits';
326
+ : process.platform === 'win32'
327
+ ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C quits'
328
+ : process.platform === 'darwin'
329
+ ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits'
330
+ : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits';
315
331
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
316
332
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
317
333
  const footerSpans = [
@@ -1,6 +1,14 @@
1
1
  import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
2
2
  import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getApprovalChoiceForCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, resolveApprovalChoiceFromInput, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
3
3
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
4
+ function isClipboardImagePasteKey(key) {
5
+ if (process.platform === 'win32') {
6
+ return ((key.ctrl || key.meta) &&
7
+ key.input.toLowerCase() === 'v' &&
8
+ !key.shift);
9
+ }
10
+ return key.ctrl && key.input === 'v' && !key.shift && !key.meta;
11
+ }
4
12
  function shouldShowCommandPalette(state) {
5
13
  if (state.busy ||
6
14
  state.exiting ||
@@ -428,7 +436,7 @@ export function handleShellKeyEvent(store, handlers, event) {
428
436
  });
429
437
  return;
430
438
  }
431
- if (key.ctrl && key.input === 'v') {
439
+ if (isClipboardImagePasteKey(key)) {
432
440
  const current = store.getState();
433
441
  if (current.busy)
434
442
  return;
@@ -0,0 +1,35 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const PACKAGE_NAME = '@thegitai/cli';
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
+ export function getCliVersion() {
12
+ let dir = path.dirname(fileURLToPath(import.meta.url));
13
+ for (let depth = 0; depth < 6; depth++) {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
16
+ if (pkg.name === PACKAGE_NAME && typeof pkg.version === 'string') {
17
+ return pkg.version;
18
+ }
19
+ }
20
+ catch {
21
+ // No package.json at this level (or unreadable); keep walking up.
22
+ }
23
+ const parent = path.dirname(dir);
24
+ if (parent === dir)
25
+ break;
26
+ dir = parent;
27
+ }
28
+ return UNKNOWN_VERSION;
29
+ }
30
+ export function getPlatformTag() {
31
+ return `${process.platform}-${process.arch}`;
32
+ }
33
+ export function formatVersionLine() {
34
+ return `ai ${getCliVersion()} (${getPlatformTag()}, node ${process.version})`;
35
+ }
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Max Brunsfeld
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ web-tree-sitter
2
+ ===============
3
+
4
+ The files `web-tree-sitter.cjs` and `web-tree-sitter.wasm` in this directory are
5
+ vendored, unmodified, from the `web-tree-sitter` npm package, version 0.26.6.
6
+
7
+ They are bundled here (rather than installed as a runtime dependency) so the
8
+ published `@thegitai/cli` package declares zero runtime dependencies while still
9
+ shipping local tree-sitter code intelligence. They are used only as a local
10
+ parsing runtime; no part of web-tree-sitter is modified.
11
+
12
+ Upstream: https://github.com/tree-sitter/tree-sitter
13
+ License: MIT (see the adjacent LICENSE file)