@thegitai/cli 1.0.0-preview.20 → 1.0.0-preview.21
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/src/api/chat.js +6 -2
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +199 -0
- package/dist/src/help-text.js +2 -2
- package/dist/src/session-store.js +62 -0
- package/dist/src/tools/index.js +2 -0
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/ui/repl.js +16 -9
- package/dist/src/ui/tui/shell-input.js +52 -19
- package/package.json +5 -5
package/dist/src/api/chat.js
CHANGED
|
@@ -248,8 +248,12 @@ async function postUserInputResult({ config, turnId, requestId, result, fetchImp
|
|
|
248
248
|
throw await readErrorResponse(response, trace.traceId);
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
|
-
export async function postInterjection({ config, turnId, text, messageId, fetchImpl = globalThis.fetch, traceId, }) {
|
|
252
|
-
const payload = {
|
|
251
|
+
export async function postInterjection({ config, turnId, text, messageId, imageAttachments = [], fetchImpl = globalThis.fetch, traceId, }) {
|
|
252
|
+
const payload = {
|
|
253
|
+
text,
|
|
254
|
+
messageId,
|
|
255
|
+
...(imageAttachments.length > 0 ? { imageAttachments } : {}),
|
|
256
|
+
};
|
|
253
257
|
const trace = createTraceContext(traceId);
|
|
254
258
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/interject`, {
|
|
255
259
|
method: 'POST',
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
const MIME_BY_EXT = {
|
|
6
|
-
'.png': 'image/png',
|
|
7
|
-
'.jpg': 'image/jpeg',
|
|
8
|
-
'.jpeg': 'image/jpeg',
|
|
9
|
-
'.gif': 'image/gif',
|
|
10
|
-
'.webp': 'image/webp',
|
|
11
|
-
};
|
|
12
|
-
const SUPPORTED_MIME_TYPES = new Set(Object.values(MIME_BY_EXT));
|
|
4
|
+
import { MAX_IMAGE_SIZE_BYTES, SUPPORTED_IMAGE_MIME_TYPES as SUPPORTED_MIME_TYPES, sniffImageMimeType, } from './image-limits.js';
|
|
13
5
|
export class ClipboardError extends Error {
|
|
14
6
|
code;
|
|
15
7
|
constructor(message, code) {
|
|
@@ -273,11 +265,13 @@ export function loadImageFromFile(filePath) {
|
|
|
273
265
|
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
274
266
|
throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
|
|
275
267
|
}
|
|
276
|
-
const
|
|
277
|
-
const mimeType =
|
|
268
|
+
const buf = readFileSync(resolved);
|
|
269
|
+
const mimeType = sniffImageMimeType(buf);
|
|
278
270
|
if (!mimeType) {
|
|
279
|
-
|
|
271
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
272
|
+
throw new ClipboardError(ext
|
|
273
|
+
? `"${path.basename(resolved)}" is named ${ext} but its contents are not a supported image. Supported: PNG, JPEG, GIF, WebP.`
|
|
274
|
+
: `Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
|
|
280
275
|
}
|
|
281
|
-
const buf = readFileSync(resolved);
|
|
282
276
|
return { base64Data: buf.toString('base64'), mimeType };
|
|
283
277
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const MAX_IMAGES_PER_MESSAGE = 5;
|
|
2
|
+
export const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
3
|
+
export const MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE = 20 * 1024 * 1024;
|
|
4
|
+
export function approximateBase64DecodedBytes(base64) {
|
|
5
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
6
|
+
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
|
7
|
+
}
|
|
8
|
+
export function totalAttachmentBytes(attachments) {
|
|
9
|
+
return attachments.reduce((sum, attachment) => sum + approximateBase64DecodedBytes(attachment.base64Data), 0);
|
|
10
|
+
}
|
|
11
|
+
export const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
|
12
|
+
'image/png',
|
|
13
|
+
'image/jpeg',
|
|
14
|
+
'image/gif',
|
|
15
|
+
'image/webp',
|
|
16
|
+
]);
|
|
17
|
+
export const IMAGE_MIME_BY_EXT = {
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.jpg': 'image/jpeg',
|
|
20
|
+
'.jpeg': 'image/jpeg',
|
|
21
|
+
'.gif': 'image/gif',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
};
|
|
24
|
+
export const IMAGE_EXT_BY_MIME = {
|
|
25
|
+
'image/png': '.png',
|
|
26
|
+
'image/jpeg': '.jpg',
|
|
27
|
+
'image/gif': '.gif',
|
|
28
|
+
'image/webp': '.webp',
|
|
29
|
+
};
|
|
30
|
+
export function isSupportedImageMimeType(mime) {
|
|
31
|
+
return SUPPORTED_IMAGE_MIME_TYPES.has(mime);
|
|
32
|
+
}
|
|
33
|
+
export function sniffImageMimeType(bytes) {
|
|
34
|
+
if (bytes.length < 12)
|
|
35
|
+
return null;
|
|
36
|
+
if (bytes[0] === 0x89 &&
|
|
37
|
+
bytes[1] === 0x50 &&
|
|
38
|
+
bytes[2] === 0x4e &&
|
|
39
|
+
bytes[3] === 0x47) {
|
|
40
|
+
return 'image/png';
|
|
41
|
+
}
|
|
42
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
43
|
+
return 'image/jpeg';
|
|
44
|
+
}
|
|
45
|
+
if (bytes.subarray(0, 3).toString('latin1') === 'GIF') {
|
|
46
|
+
return 'image/gif';
|
|
47
|
+
}
|
|
48
|
+
if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
49
|
+
bytes.subarray(8, 12).toString('latin1') === 'WEBP') {
|
|
50
|
+
return 'image/webp';
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
export function imageExtensionForMime(mime) {
|
|
55
|
+
return IMAGE_EXT_BY_MIME[mime] ?? '.png';
|
|
56
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs';
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { loadImageFromFile } from './clipboard.js';
|
|
5
|
+
import { MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_SIZE_BYTES, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, sniffImageMimeType, totalAttachmentBytes, } from './image-limits.js';
|
|
6
|
+
import { tryCacheAttachmentBytes } from './session-image-store.js';
|
|
5
7
|
const EXT = '(?:png|jpe?g|gif|webp)';
|
|
6
8
|
const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
|
|
7
9
|
const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
|
|
@@ -110,13 +112,67 @@ function detectImagePaths(input, cwd) {
|
|
|
110
112
|
}
|
|
111
113
|
return rawsByPath;
|
|
112
114
|
}
|
|
115
|
+
const EXTENSIONLESS_CANDIDATE = new RegExp(`"([^"]*[\\\\/][^"]*)"` +
|
|
116
|
+
`|'([^']*[\\\\/][^']*)'` +
|
|
117
|
+
`|((?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})*[\\\\/](?:\\\\ |${BARE_CHAR})+)`, 'g');
|
|
118
|
+
function sniffFileHeader(resolvedPath) {
|
|
119
|
+
let fd = null;
|
|
120
|
+
try {
|
|
121
|
+
const stat = statSync(resolvedPath, { throwIfNoEntry: false });
|
|
122
|
+
if (!stat?.isFile() || stat.size > MAX_IMAGE_SIZE_BYTES)
|
|
123
|
+
return null;
|
|
124
|
+
fd = openSync(resolvedPath, 'r');
|
|
125
|
+
const header = Buffer.alloc(12);
|
|
126
|
+
const read = readSync(fd, header, 0, 12, 0);
|
|
127
|
+
return read === 12 ? header : null;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (fd !== null) {
|
|
134
|
+
try {
|
|
135
|
+
closeSync(fd);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function detectExtensionlessImagePaths(input, cwd, alreadyDetected) {
|
|
143
|
+
const found = new Map();
|
|
144
|
+
const regex = new RegExp(EXTENSIONLESS_CANDIDATE.source, EXTENSIONLESS_CANDIDATE.flags);
|
|
145
|
+
let match;
|
|
146
|
+
while ((match = regex.exec(input)) !== null) {
|
|
147
|
+
const raw = match[0];
|
|
148
|
+
const inner = (match[1] ?? match[2] ?? match[3] ?? '').replace(/\\ /g, ' ');
|
|
149
|
+
if (!inner || inner.includes('://'))
|
|
150
|
+
continue;
|
|
151
|
+
if (/\.(?:png|jpe?g|gif|webp)$/i.test(inner))
|
|
152
|
+
continue;
|
|
153
|
+
const resolvedPath = path.isAbsolute(inner)
|
|
154
|
+
? inner
|
|
155
|
+
: path.resolve(cwd, inner);
|
|
156
|
+
if (alreadyDetected.has(resolvedPath) || found.has(resolvedPath))
|
|
157
|
+
continue;
|
|
158
|
+
const header = sniffFileHeader(resolvedPath);
|
|
159
|
+
if (!header || !sniffImageMimeType(header))
|
|
160
|
+
continue;
|
|
161
|
+
found.set(resolvedPath, [raw]);
|
|
162
|
+
}
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
113
165
|
export function autoAttachImages(input, cwd, existing = []) {
|
|
114
|
-
const max =
|
|
166
|
+
const max = MAX_IMAGES_PER_MESSAGE;
|
|
115
167
|
const rawsByPath = detectImagePaths(input, cwd);
|
|
168
|
+
for (const [resolvedPath, rawForms] of detectExtensionlessImagePaths(input, cwd, new Set(rawsByPath.keys()))) {
|
|
169
|
+
rawsByPath.set(resolvedPath, rawForms);
|
|
170
|
+
}
|
|
116
171
|
let sanitizedInput = input;
|
|
117
172
|
const attachments = [];
|
|
118
173
|
const errors = [];
|
|
119
174
|
const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
|
|
175
|
+
let budgetUsed = totalAttachmentBytes(existing);
|
|
120
176
|
for (const [resolvedPath, rawForms] of rawsByPath) {
|
|
121
177
|
if (existing.length + attachments.length >= max)
|
|
122
178
|
break;
|
|
@@ -124,13 +180,24 @@ export function autoAttachImages(input, cwd, existing = []) {
|
|
|
124
180
|
continue;
|
|
125
181
|
try {
|
|
126
182
|
const loaded = loadImageFromFile(resolvedPath);
|
|
127
|
-
const
|
|
183
|
+
const bytes = approximateBase64DecodedBytes(loaded.base64Data);
|
|
184
|
+
if (budgetUsed + bytes > MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
|
|
185
|
+
errors.push(`${path.basename(resolvedPath)} was not attached: it would put this message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit.`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
budgetUsed += bytes;
|
|
189
|
+
const cached = tryCacheAttachmentBytes({
|
|
190
|
+
base64Data: loaded.base64Data,
|
|
191
|
+
mimeType: loaded.mimeType,
|
|
192
|
+
});
|
|
193
|
+
const idx = cached?.index ?? maxExistingIndex + attachments.length + 1;
|
|
128
194
|
attachments.push({
|
|
129
195
|
index: idx,
|
|
130
196
|
mimeType: loaded.mimeType,
|
|
131
197
|
base64Data: loaded.base64Data,
|
|
132
198
|
source: 'file',
|
|
133
199
|
filePath: resolvedPath,
|
|
200
|
+
...(cached ? { cachePath: cached.cachePath } : {}),
|
|
134
201
|
});
|
|
135
202
|
for (const raw of rawForms) {
|
|
136
203
|
sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { getClientStateDir } from '../client-state.js';
|
|
5
|
+
import { MAX_IMAGE_SIZE_BYTES, imageExtensionForMime, isSupportedImageMimeType, sniffImageMimeType, } from './image-limits.js';
|
|
6
|
+
const IMAGE_FILE_MODE = 0o600;
|
|
7
|
+
const IMAGE_DIR_MODE = 0o700;
|
|
8
|
+
export const SESSION_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
9
|
+
let activeSessionId = null;
|
|
10
|
+
export function setImageStoreSession(sessionId) {
|
|
11
|
+
activeSessionId = String(sessionId ?? '').trim() || null;
|
|
12
|
+
contentIndex.clear();
|
|
13
|
+
}
|
|
14
|
+
const contentIndex = new Map();
|
|
15
|
+
function imageContentKey(base64Data) {
|
|
16
|
+
return createHash('sha256').update(base64Data).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function rememberStoredImage(base64Data, cachePath) {
|
|
19
|
+
contentIndex.set(imageContentKey(base64Data), cachePath);
|
|
20
|
+
}
|
|
21
|
+
export function findStoredImageByContent(base64Data) {
|
|
22
|
+
const cachePath = contentIndex.get(imageContentKey(base64Data));
|
|
23
|
+
if (!cachePath)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (!existsSync(cachePath)) {
|
|
26
|
+
contentIndex.delete(imageContentKey(base64Data));
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return cachePath;
|
|
30
|
+
}
|
|
31
|
+
export function getImageStoreSession() {
|
|
32
|
+
return activeSessionId;
|
|
33
|
+
}
|
|
34
|
+
function safeSessionDirName(sessionId) {
|
|
35
|
+
const normalized = String(sessionId ?? '').trim();
|
|
36
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
|
|
37
|
+
throw new Error(`Invalid session id "${sessionId}".`);
|
|
38
|
+
}
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
export function getSessionImageBaseDir(env = process.env) {
|
|
42
|
+
return path.join(getClientStateDir(env), 'sessions', 'images');
|
|
43
|
+
}
|
|
44
|
+
export function getSessionImageDir(sessionId, env = process.env) {
|
|
45
|
+
return path.join(getSessionImageBaseDir(env), safeSessionDirName(sessionId));
|
|
46
|
+
}
|
|
47
|
+
function nextImageNumber(dir) {
|
|
48
|
+
if (!existsSync(dir))
|
|
49
|
+
return 1;
|
|
50
|
+
let highest = 0;
|
|
51
|
+
for (const name of readdirSync(dir)) {
|
|
52
|
+
const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
|
|
53
|
+
if (Number.isInteger(parsed) && parsed > highest)
|
|
54
|
+
highest = parsed;
|
|
55
|
+
}
|
|
56
|
+
return highest + 1;
|
|
57
|
+
}
|
|
58
|
+
function ensureSessionImageDir(sessionId, env) {
|
|
59
|
+
const dir = getSessionImageDir(sessionId, env);
|
|
60
|
+
mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
export function storeSessionImageBytes({ sessionId, base64Data, mimeType, env = process.env, }) {
|
|
64
|
+
const dir = ensureSessionImageDir(sessionId, env);
|
|
65
|
+
const index = nextImageNumber(dir);
|
|
66
|
+
const cachePath = path.join(dir, `${index}${imageExtensionForMime(mimeType)}`);
|
|
67
|
+
writeFileSync(cachePath, Buffer.from(base64Data, 'base64'), {
|
|
68
|
+
mode: IMAGE_FILE_MODE,
|
|
69
|
+
});
|
|
70
|
+
rememberStoredImage(base64Data, cachePath);
|
|
71
|
+
return { cachePath, mimeType, base64Data, index };
|
|
72
|
+
}
|
|
73
|
+
export function tryCacheAttachmentBytes({ base64Data, mimeType, env = process.env, }) {
|
|
74
|
+
if (!activeSessionId)
|
|
75
|
+
return undefined;
|
|
76
|
+
try {
|
|
77
|
+
const stored = storeSessionImageBytes({
|
|
78
|
+
sessionId: activeSessionId,
|
|
79
|
+
base64Data,
|
|
80
|
+
mimeType,
|
|
81
|
+
env,
|
|
82
|
+
});
|
|
83
|
+
return { cachePath: stored.cachePath, index: stored.index };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function isSessionStorePath(candidate, env = process.env) {
|
|
90
|
+
if (!activeSessionId)
|
|
91
|
+
return false;
|
|
92
|
+
const dir = getSessionImageDir(activeSessionId, env);
|
|
93
|
+
const resolved = path.resolve(candidate);
|
|
94
|
+
return (resolved.startsWith(dir + path.sep) && path.dirname(resolved) === dir);
|
|
95
|
+
}
|
|
96
|
+
export function readSessionImageByIndex(index, env = process.env) {
|
|
97
|
+
if (!activeSessionId || !Number.isInteger(index) || index < 1)
|
|
98
|
+
return null;
|
|
99
|
+
const dir = getSessionImageDir(activeSessionId, env);
|
|
100
|
+
if (!existsSync(dir))
|
|
101
|
+
return null;
|
|
102
|
+
try {
|
|
103
|
+
for (const name of readdirSync(dir)) {
|
|
104
|
+
if (Number.parseInt(path.basename(name, path.extname(name)), 10) === index) {
|
|
105
|
+
return readSessionImage(path.join(dir, name));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
export class SessionImageError extends Error {
|
|
115
|
+
code;
|
|
116
|
+
constructor(message, code) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.code = code;
|
|
119
|
+
this.name = 'SessionImageError';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function storeSessionImageFromPath({ sessionId, sourcePath, env = process.env, }) {
|
|
123
|
+
const resolved = path.resolve(sourcePath);
|
|
124
|
+
if (!existsSync(resolved) || !statSync(resolved).isFile()) {
|
|
125
|
+
throw new SessionImageError(`Image file not found: ${resolved}`, 'NOT_FOUND');
|
|
126
|
+
}
|
|
127
|
+
const size = statSync(resolved).size;
|
|
128
|
+
if (size > MAX_IMAGE_SIZE_BYTES) {
|
|
129
|
+
throw new SessionImageError(`Image file exceeds ${Math.round(MAX_IMAGE_SIZE_BYTES / 1024 / 1024)}MB limit (${(size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'TOO_LARGE');
|
|
130
|
+
}
|
|
131
|
+
const bytes = readFileSync(resolved);
|
|
132
|
+
const sniffed = sniffImageMimeType(bytes);
|
|
133
|
+
if (!sniffed || !isSupportedImageMimeType(sniffed)) {
|
|
134
|
+
throw new SessionImageError(`Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'UNSUPPORTED');
|
|
135
|
+
}
|
|
136
|
+
const dir = ensureSessionImageDir(sessionId, env);
|
|
137
|
+
const index = nextImageNumber(dir);
|
|
138
|
+
const cachePath = path.join(dir, `${index}${imageExtensionForMime(sniffed)}`);
|
|
139
|
+
writeFileSync(cachePath, bytes, { mode: IMAGE_FILE_MODE });
|
|
140
|
+
const base64Data = bytes.toString('base64');
|
|
141
|
+
rememberStoredImage(base64Data, cachePath);
|
|
142
|
+
return { cachePath, mimeType: sniffed, base64Data, index };
|
|
143
|
+
}
|
|
144
|
+
export function readSessionImage(cachePath) {
|
|
145
|
+
try {
|
|
146
|
+
if (!existsSync(cachePath))
|
|
147
|
+
return null;
|
|
148
|
+
const bytes = readFileSync(cachePath);
|
|
149
|
+
const sniffed = sniffImageMimeType(bytes);
|
|
150
|
+
if (!sniffed)
|
|
151
|
+
return null;
|
|
152
|
+
const parsed = Number.parseInt(path.basename(cachePath, path.extname(cachePath)), 10);
|
|
153
|
+
const base64Data = bytes.toString('base64');
|
|
154
|
+
rememberStoredImage(base64Data, cachePath);
|
|
155
|
+
return {
|
|
156
|
+
cachePath,
|
|
157
|
+
mimeType: sniffed,
|
|
158
|
+
base64Data,
|
|
159
|
+
index: Number.isInteger(parsed) ? parsed : 0,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export function pruneSessionImages(sessionId, env = process.env) {
|
|
167
|
+
try {
|
|
168
|
+
rmSync(getSessionImageDir(sessionId, env), { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export function sweepOrphanSessionImages({ activeSessionIds, maxAgeMs = SESSION_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), }) {
|
|
174
|
+
const baseDir = getSessionImageBaseDir(env);
|
|
175
|
+
if (!existsSync(baseDir))
|
|
176
|
+
return 0;
|
|
177
|
+
let removed = 0;
|
|
178
|
+
let entries;
|
|
179
|
+
try {
|
|
180
|
+
entries = readdirSync(baseDir);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (activeSessionIds.has(entry))
|
|
187
|
+
continue;
|
|
188
|
+
const dir = path.join(baseDir, entry);
|
|
189
|
+
try {
|
|
190
|
+
if (now - statSync(dir).mtimeMs < maxAgeMs)
|
|
191
|
+
continue;
|
|
192
|
+
rmSync(dir, { recursive: true, force: true });
|
|
193
|
+
removed += 1;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return removed;
|
|
199
|
+
}
|
package/dist/src/help-text.js
CHANGED
|
@@ -66,8 +66,8 @@ const HELP_MARKDOWN = [
|
|
|
66
66
|
' picks it up at its next step instead of waiting for the turn to finish, and',
|
|
67
67
|
' the Your messages panel tracks it from queued to delivered. **↑** brings it',
|
|
68
68
|
' back for editing and **Esc**/**Ctrl+C** discards it; both unlock the',
|
|
69
|
-
' composer for the next message.
|
|
70
|
-
' and
|
|
69
|
+
' composer for the next message. **Ctrl+V** pastes a screenshot onto the',
|
|
70
|
+
' queued message, and it is delivered into the running turn with the text.',
|
|
71
71
|
`- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
|
|
72
72
|
' on this system) or by right-clicking the composer.',
|
|
73
73
|
'- **Copy** from the transcript by dragging to select; double-click copies a',
|
|
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { getClientStateDir } from './client-state.js';
|
|
6
|
+
import { findStoredImageByContent, pruneSessionImages, readSessionImage, sweepOrphanSessionImages, } from './core/session-image-store.js';
|
|
6
7
|
import { normalizeAssistantEditJournal } from './edit-journal.js';
|
|
7
8
|
import { createSessionGrants } from './permissions.js';
|
|
8
9
|
import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
|
|
@@ -85,6 +86,39 @@ function normalizeBranch(value) {
|
|
|
85
86
|
.trim();
|
|
86
87
|
return text ? text.slice(0, 120) : null;
|
|
87
88
|
}
|
|
89
|
+
function dehydrateHistoryImages(history) {
|
|
90
|
+
return history.map((entry) => ({
|
|
91
|
+
...entry,
|
|
92
|
+
parts: (entry.parts ?? []).map((part) => {
|
|
93
|
+
if (!part?.inlineData?.data)
|
|
94
|
+
return part;
|
|
95
|
+
const cachePath = part.imageCachePath ?? findStoredImageByContent(part.inlineData.data);
|
|
96
|
+
if (!cachePath)
|
|
97
|
+
return part;
|
|
98
|
+
return {
|
|
99
|
+
imageRef: { cachePath, mimeType: part.inlineData.mimeType },
|
|
100
|
+
text: '[image stored in this session]',
|
|
101
|
+
};
|
|
102
|
+
}),
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
function rehydrateHistoryImages(history) {
|
|
106
|
+
return history.map((entry) => ({
|
|
107
|
+
...entry,
|
|
108
|
+
parts: (entry.parts ?? []).map((part) => {
|
|
109
|
+
const ref = part?.imageRef;
|
|
110
|
+
if (!ref?.cachePath)
|
|
111
|
+
return part;
|
|
112
|
+
const stored = readSessionImage(String(ref.cachePath));
|
|
113
|
+
if (!stored)
|
|
114
|
+
return { text: part.text ?? '[image no longer available]' };
|
|
115
|
+
return {
|
|
116
|
+
inlineData: { mimeType: stored.mimeType, data: stored.base64Data },
|
|
117
|
+
imageCachePath: ref.cachePath,
|
|
118
|
+
};
|
|
119
|
+
}),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
88
122
|
function normalizeHistory(value) {
|
|
89
123
|
if (!Array.isArray(value))
|
|
90
124
|
return [];
|
|
@@ -154,14 +188,42 @@ function assertSessionNameAvailable(rootDir, name, sessionId, env = process.env)
|
|
|
154
188
|
throw new Error(`Session name "${name}" is already used by ${duplicate.id}. Use --session "${name}" to resume it or choose a different name.`);
|
|
155
189
|
}
|
|
156
190
|
}
|
|
191
|
+
function listAllSessionIds(env = process.env) {
|
|
192
|
+
const ids = new Set();
|
|
193
|
+
const projectsDir = path.join(getSessionBaseDir(env), 'sessions', 'projects');
|
|
194
|
+
if (!existsSync(projectsDir))
|
|
195
|
+
return ids;
|
|
196
|
+
try {
|
|
197
|
+
for (const project of readdirSync(projectsDir)) {
|
|
198
|
+
const dir = path.join(projectsDir, project);
|
|
199
|
+
try {
|
|
200
|
+
for (const file of readdirSync(dir)) {
|
|
201
|
+
if (file.endsWith('.json'))
|
|
202
|
+
ids.add(path.basename(file, '.json'));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return ids;
|
|
211
|
+
}
|
|
212
|
+
return ids;
|
|
213
|
+
}
|
|
157
214
|
export function pruneSavedSessions(rootDir, env = process.env) {
|
|
158
215
|
const snapshots = loadAllSnapshots(rootDir, env);
|
|
159
216
|
const keep = new Set(snapshots.slice(0, MAX_RECENT_SESSIONS).map((snapshot) => snapshot.id));
|
|
160
217
|
for (const snapshot of snapshots.slice(MAX_RECENT_SESSIONS)) {
|
|
161
218
|
if (!keep.has(snapshot.id)) {
|
|
162
219
|
rmSync(getSessionPath(rootDir, snapshot.id, env), { force: true });
|
|
220
|
+
pruneSessionImages(snapshot.id, env);
|
|
163
221
|
}
|
|
164
222
|
}
|
|
223
|
+
sweepOrphanSessionImages({
|
|
224
|
+
activeSessionIds: listAllSessionIds(env),
|
|
225
|
+
env,
|
|
226
|
+
});
|
|
165
227
|
}
|
|
166
228
|
export function readGitBranch(rootDir) {
|
|
167
229
|
try {
|
package/dist/src/tools/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { shellJobKill } from './shell-job-kill.js';
|
|
|
22
22
|
import { strReplace } from './str-replace.js';
|
|
23
23
|
import { undoEdit } from './undo-edit.js';
|
|
24
24
|
import { updateTodos } from './update-todos.js';
|
|
25
|
+
import { readImageFile } from './read-image-file.js';
|
|
25
26
|
import { writeFile } from './write-file.js';
|
|
26
27
|
export const TOOL_MAP = {
|
|
27
28
|
search_code: (context, args) => searchCode(context.projectIndex, args),
|
|
@@ -50,6 +51,7 @@ export const TOOL_MAP = {
|
|
|
50
51
|
shell_job_output: shellJobOutput,
|
|
51
52
|
shell_job_kill: shellJobKill,
|
|
52
53
|
update_todos: updateTodos,
|
|
54
|
+
analyze_image: (context, args) => readImageFile(context, args),
|
|
53
55
|
};
|
|
54
56
|
function invalidToolCall(error) {
|
|
55
57
|
return {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
|
+
import { SessionImageError, getImageStoreSession, isSessionStorePath, readSessionImage, readSessionImageByIndex, storeSessionImageFromPath, } from '../core/session-image-store.js';
|
|
4
|
+
export async function readImageFile(context, args) {
|
|
5
|
+
const rawPath = String(args.path ?? args.filePath ?? args.file_path ?? '').trim();
|
|
6
|
+
if (!rawPath) {
|
|
7
|
+
const requested = Number(args.imageIndex ?? args.image_index ?? args.index);
|
|
8
|
+
const stored = Number.isInteger(requested)
|
|
9
|
+
? readSessionImageByIndex(requested)
|
|
10
|
+
: null;
|
|
11
|
+
if (!stored) {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
error: Number.isInteger(requested)
|
|
15
|
+
? `Image #${requested} is not in this session's image store.`
|
|
16
|
+
: 'path is required and must name an image file on this machine.',
|
|
17
|
+
failureCategory: Number.isInteger(requested)
|
|
18
|
+
? 'not_found'
|
|
19
|
+
: 'missing_required_argument',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
imageBytes: {
|
|
25
|
+
base64Data: stored.base64Data,
|
|
26
|
+
mimeType: stored.mimeType,
|
|
27
|
+
cachePath: stored.cachePath,
|
|
28
|
+
index: stored.index,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
let resolved;
|
|
33
|
+
if (path.isAbsolute(rawPath)) {
|
|
34
|
+
resolved = rawPath;
|
|
35
|
+
}
|
|
36
|
+
else if (normalizeProjectRelativePath(context.rootDir, rawPath)) {
|
|
37
|
+
resolved = path.resolve(context.rootDir, rawPath);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: `Refusing to access path outside the project root: ${rawPath}`,
|
|
43
|
+
failureCategory: 'permission_denied',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const stored = isSessionStorePath(resolved)
|
|
48
|
+
? readSessionImage(resolved)
|
|
49
|
+
: storeSessionImageFromPath({
|
|
50
|
+
sessionId: getImageStoreSession() ?? 'unbound',
|
|
51
|
+
sourcePath: resolved,
|
|
52
|
+
});
|
|
53
|
+
if (!stored) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: `Not a readable image: ${resolved}`,
|
|
57
|
+
failureCategory: 'not_found',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
imageBytes: {
|
|
63
|
+
base64Data: stored.base64Data,
|
|
64
|
+
mimeType: stored.mimeType,
|
|
65
|
+
filePath: resolved,
|
|
66
|
+
cachePath: stored.cachePath,
|
|
67
|
+
index: stored.index,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err instanceof SessionImageError) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: err.message,
|
|
76
|
+
failureCategory: err.code === 'NOT_FOUND' ? 'not_found' : 'invalid_argument',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `Could not read image: ${err?.message ?? err}`,
|
|
82
|
+
failureCategory: 'tool_exception',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/src/ui/repl.js
CHANGED
|
@@ -10,6 +10,7 @@ import { isTurnCancelledError } from '../api/chat.js';
|
|
|
10
10
|
import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
|
|
11
11
|
import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
|
|
12
12
|
import { setScratchSession } from '../scratch-dir.js';
|
|
13
|
+
import { setImageStoreSession } from '../core/session-image-store.js';
|
|
13
14
|
import { cancelActiveCommand } from '../executor.js';
|
|
14
15
|
import { isTurnFailureMarker } from '../turn-failure-marker.js';
|
|
15
16
|
import { clearCliAuthConfig } from '../api/auth.js';
|
|
@@ -1347,6 +1348,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1347
1348
|
await withTuiMode(async () => {
|
|
1348
1349
|
setBackgroundJobSession(session.sessionId);
|
|
1349
1350
|
setScratchSession(session.sessionId);
|
|
1351
|
+
setImageStoreSession(session.sessionId);
|
|
1350
1352
|
setTodoSession(session.sessionId);
|
|
1351
1353
|
const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
|
|
1352
1354
|
store.replaceTranscript(createSessionTranscript(session));
|
|
@@ -2163,6 +2165,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2163
2165
|
applySessionSnapshot(session, snapshot);
|
|
2164
2166
|
setBackgroundJobSession(session.sessionId);
|
|
2165
2167
|
setScratchSession(session.sessionId);
|
|
2168
|
+
setImageStoreSession(session.sessionId);
|
|
2166
2169
|
syncBackgroundJobsState();
|
|
2167
2170
|
setTodoSession(session.sessionId);
|
|
2168
2171
|
syncTodosState();
|
|
@@ -2267,14 +2270,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2267
2270
|
const text = (queued.pastedChunks.length
|
|
2268
2271
|
? expandPastedChunks(queued.body, queued.pastedChunks)
|
|
2269
2272
|
: queued.body).trim();
|
|
2270
|
-
|
|
2273
|
+
const images = queued.imageAttachments.filter((attachment) => queued.body.includes(`[Image #${attachment.index}]`));
|
|
2274
|
+
if (!text && images.length === 0)
|
|
2271
2275
|
return;
|
|
2272
2276
|
const turnId = activeServerTurnId;
|
|
2273
|
-
const blockedReason =
|
|
2274
|
-
? 'waiting — images go with the next prompt'
|
|
2275
|
-
: !turnId
|
|
2276
|
-
? 'waiting for the turn to end'
|
|
2277
|
-
: null;
|
|
2277
|
+
const blockedReason = !turnId ? 'waiting for the turn to end' : null;
|
|
2278
2278
|
const existingRow = blockedRows.get(queued);
|
|
2279
2279
|
if (existingRow !== undefined) {
|
|
2280
2280
|
upsertTurnMessage(existingRow, {
|
|
@@ -2331,6 +2331,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2331
2331
|
turnId,
|
|
2332
2332
|
text,
|
|
2333
2333
|
messageId,
|
|
2334
|
+
imageAttachments: images,
|
|
2334
2335
|
});
|
|
2335
2336
|
}
|
|
2336
2337
|
catch (error) {
|
|
@@ -2360,9 +2361,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2360
2361
|
return;
|
|
2361
2362
|
}
|
|
2362
2363
|
const pending = store.getState();
|
|
2364
|
+
const body = String(rawInput ?? '');
|
|
2363
2365
|
const snapshot = {
|
|
2364
|
-
body
|
|
2365
|
-
imageAttachments: pending.imageAttachments,
|
|
2366
|
+
body,
|
|
2367
|
+
imageAttachments: pending.imageAttachments.filter((attachment) => body.includes(`[Image #${attachment.index}]`)),
|
|
2366
2368
|
pastedChunks: pending.pastedChunks,
|
|
2367
2369
|
};
|
|
2368
2370
|
scheduleLiveFrameRemount();
|
|
@@ -2377,11 +2379,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2377
2379
|
}));
|
|
2378
2380
|
return;
|
|
2379
2381
|
}
|
|
2380
|
-
const imageAttachments = store
|
|
2382
|
+
const imageAttachments = store
|
|
2383
|
+
.getState()
|
|
2384
|
+
.imageAttachments.filter((attachment) => input.includes(`[Image #${attachment.index}]`));
|
|
2381
2385
|
scheduleLiveFrameRemount();
|
|
2382
2386
|
store.update((current) => ({
|
|
2383
2387
|
...current,
|
|
2384
2388
|
cursor: 0,
|
|
2389
|
+
imageAttachments: [],
|
|
2385
2390
|
input: '',
|
|
2386
2391
|
pastedChunks: [],
|
|
2387
2392
|
promptHistory: appendPromptToHistory(current.promptHistory, input),
|
|
@@ -2564,6 +2569,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2564
2569
|
startNewConversation(session);
|
|
2565
2570
|
setBackgroundJobSession(session.sessionId);
|
|
2566
2571
|
setScratchSession(session.sessionId);
|
|
2572
|
+
setImageStoreSession(session.sessionId);
|
|
2567
2573
|
setTodoSession(session.sessionId);
|
|
2568
2574
|
clearTodos();
|
|
2569
2575
|
syncBackgroundJobsState();
|
|
@@ -2975,6 +2981,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2975
2981
|
killAllBackgroundJobs();
|
|
2976
2982
|
setBackgroundJobSession(null);
|
|
2977
2983
|
setScratchSession(null);
|
|
2984
|
+
setImageStoreSession(null);
|
|
2978
2985
|
setTodoSession(null);
|
|
2979
2986
|
setBackgroundJobUpdateHook(null);
|
|
2980
2987
|
await bridge.close();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
|
|
2
|
+
import { MAX_IMAGES_PER_MESSAGE, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, totalAttachmentBytes, } from '../../core/image-limits.js';
|
|
3
|
+
import { tryCacheAttachmentBytes } from '../../core/session-image-store.js';
|
|
2
4
|
import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
|
|
3
5
|
import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
|
|
4
6
|
import { handleUserInputPromptEvent, } from './user-input.js';
|
|
@@ -446,7 +448,7 @@ export function handleShellKeyEvent(store, handlers, event) {
|
|
|
446
448
|
void handlers.onFireQueuedMessage?.();
|
|
447
449
|
return;
|
|
448
450
|
}
|
|
449
|
-
if (!key.upArrow) {
|
|
451
|
+
if (!key.upArrow && !isClipboardImagePasteKey(key)) {
|
|
450
452
|
return;
|
|
451
453
|
}
|
|
452
454
|
}
|
|
@@ -577,12 +579,16 @@ export function handleShellKeyEvent(store, handlers, event) {
|
|
|
577
579
|
}
|
|
578
580
|
if (isClipboardImagePasteKey(key)) {
|
|
579
581
|
const current = store.getState();
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
582
|
+
const target = current.queuedMessage
|
|
583
|
+
? {
|
|
584
|
+
body: current.queuedMessage.body,
|
|
585
|
+
attachments: current.queuedMessage.imageAttachments,
|
|
586
|
+
}
|
|
587
|
+
: { body: current.input, attachments: current.imageAttachments };
|
|
588
|
+
const liveAttachments = target.attachments.filter((attachment) => target.body.includes(`[Image #${attachment.index}]`));
|
|
589
|
+
if (liveAttachments.length >= MAX_IMAGES_PER_MESSAGE) {
|
|
584
590
|
store.appendEntry({
|
|
585
|
-
body:
|
|
591
|
+
body: `Maximum of ${MAX_IMAGES_PER_MESSAGE} images per message. Send the current message first.`,
|
|
586
592
|
kind: 'error',
|
|
587
593
|
title: 'Image',
|
|
588
594
|
});
|
|
@@ -590,22 +596,49 @@ export function handleShellKeyEvent(store, handlers, event) {
|
|
|
590
596
|
}
|
|
591
597
|
try {
|
|
592
598
|
const clipResult = readClipboardImage();
|
|
599
|
+
if (totalAttachmentBytes(liveAttachments) +
|
|
600
|
+
approximateBase64DecodedBytes(clipResult.base64Data) >
|
|
601
|
+
MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
|
|
602
|
+
store.appendEntry({
|
|
603
|
+
body: `This image would put the message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit. Send the current message first.`,
|
|
604
|
+
kind: 'error',
|
|
605
|
+
title: 'Image',
|
|
606
|
+
});
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
593
609
|
const idx = liveAttachments.length === 0
|
|
594
610
|
? 1
|
|
595
611
|
: Math.max(...liveAttachments.map((attachment) => attachment.index)) + 1;
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
612
|
+
const cached = tryCacheAttachmentBytes({
|
|
613
|
+
base64Data: clipResult.base64Data,
|
|
614
|
+
mimeType: clipResult.mimeType,
|
|
615
|
+
});
|
|
616
|
+
const attachment = {
|
|
617
|
+
index: cached?.index ?? idx,
|
|
618
|
+
mimeType: clipResult.mimeType,
|
|
619
|
+
base64Data: clipResult.base64Data,
|
|
620
|
+
source: 'clipboard',
|
|
621
|
+
...(cached ? { cachePath: cached.cachePath } : {}),
|
|
622
|
+
};
|
|
623
|
+
const marker = attachment.index;
|
|
624
|
+
store.update((shellState) => {
|
|
625
|
+
const nextAttachments = [...liveAttachments, attachment];
|
|
626
|
+
if (shellState.queuedMessage) {
|
|
627
|
+
return {
|
|
628
|
+
...shellState,
|
|
629
|
+
queuedMessage: {
|
|
630
|
+
...shellState.queuedMessage,
|
|
631
|
+
body: `${shellState.queuedMessage.body} [Image #${marker}]`.trim(),
|
|
632
|
+
imageAttachments: nextAttachments,
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
...shellState,
|
|
638
|
+
imageAttachments: nextAttachments,
|
|
639
|
+
...insertAtCursor(shellState, `[Image #${marker}] `),
|
|
640
|
+
};
|
|
641
|
+
});
|
|
609
642
|
}
|
|
610
643
|
catch (error) {
|
|
611
644
|
if (error?.code === 'NO_IMAGE') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegitai/cli",
|
|
3
|
-
"version": "1.0.0-preview.
|
|
3
|
+
"version": "1.0.0-preview.21",
|
|
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.
|
|
41
|
-
"@thegitai/tui-darwin-x64": "1.0.0-preview.
|
|
42
|
-
"@thegitai/tui-linux-x64": "1.0.0-preview.
|
|
43
|
-
"@thegitai/tui-win32-x64": "1.0.0-preview.
|
|
40
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-preview.21",
|
|
41
|
+
"@thegitai/tui-darwin-x64": "1.0.0-preview.21",
|
|
42
|
+
"@thegitai/tui-linux-x64": "1.0.0-preview.21",
|
|
43
|
+
"@thegitai/tui-win32-x64": "1.0.0-preview.21",
|
|
44
44
|
"@vscode/ripgrep": "1.18.0"
|
|
45
45
|
},
|
|
46
46
|
"publishConfig": {
|