@thegitai/cli 1.0.0-preview.1 → 1.0.0-preview.3
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 +4 -0
- package/dist/src/api/chat.js +2 -0
- package/dist/src/api/models.js +7 -2
- package/dist/src/patcher.js +96 -9
- package/dist/src/project-orientation.js +99 -0
- package/dist/src/scratch-dir.js +51 -33
- package/dist/src/tool-executor.js +38 -6
- package/dist/src/tools/delete-file.js +22 -4
- package/dist/src/tools/patch-file.js +30 -5
- package/dist/src/tools/read-file.js +3 -1
- package/dist/src/tools/replace-document-text.js +7 -1
- package/dist/src/tools/run-command.js +37 -19
- package/dist/src/tools/run-node-script.js +24 -4
- package/dist/src/tools/str-replace.js +30 -5
- package/dist/src/tools/write-file.js +25 -5
- package/dist/src/ui/repl.js +34 -1
- package/dist/src/ui/tui/build-frame.js +97 -58
- package/package.json +5 -5
package/dist/bin/ai.js
CHANGED
|
@@ -18,6 +18,7 @@ import { formatVersionLine } from '../src/version.js';
|
|
|
18
18
|
import { parseArgs } from '../src/cli-args.js';
|
|
19
19
|
import { getJobBufferedOutput, killBackgroundJob, killAllBackgroundJobs, listBackgroundJobs, setBackgroundJobSession, } from '../src/background-jobs.js';
|
|
20
20
|
import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../src/tool-executor.js';
|
|
21
|
+
import { setScratchSession } from '../src/scratch-dir.js';
|
|
21
22
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
22
23
|
const { auth, chat, models, sessions } = ServerApi;
|
|
23
24
|
function printUsage() {
|
|
@@ -318,6 +319,7 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
318
319
|
}
|
|
319
320
|
applySessionSnapshot(session, snapshot);
|
|
320
321
|
setBackgroundJobSession(session.sessionId);
|
|
322
|
+
setScratchSession(session.sessionId);
|
|
321
323
|
await saveSessionBoth({ session, serverSessionClient });
|
|
322
324
|
console.log(chalk.dim(`Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})\n`));
|
|
323
325
|
continue;
|
|
@@ -454,6 +456,7 @@ export async function main() {
|
|
|
454
456
|
applySessionSnapshot(session, sourceSnapshot);
|
|
455
457
|
await saveSessionBoth({ session, serverSessionClient });
|
|
456
458
|
}
|
|
459
|
+
setScratchSession(session.sessionId);
|
|
457
460
|
const projectIndex = createIndex({
|
|
458
461
|
rootDir,
|
|
459
462
|
onStatus: (message) => {
|
|
@@ -503,6 +506,7 @@ export async function main() {
|
|
|
503
506
|
finally {
|
|
504
507
|
killAllBackgroundJobs({ sessionId: session.sessionId, remove: true });
|
|
505
508
|
setBackgroundJobSession(null);
|
|
509
|
+
setScratchSession(null);
|
|
506
510
|
}
|
|
507
511
|
}
|
|
508
512
|
main().catch((error) => {
|
package/dist/src/api/chat.js
CHANGED
|
@@ -4,6 +4,7 @@ import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js'
|
|
|
4
4
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
5
5
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
6
6
|
import { collectClientEnvironment } from '../client-environment.js';
|
|
7
|
+
import { collectProjectOrientation } from '../project-orientation.js';
|
|
7
8
|
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
8
9
|
export class TurnCancelledError extends Error {
|
|
9
10
|
name = 'TurnCancelledError';
|
|
@@ -339,6 +340,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
339
340
|
input: requestInputBase,
|
|
340
341
|
backgroundJobUpdate: backgroundJobUpdate || undefined,
|
|
341
342
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
343
|
+
projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
|
|
342
344
|
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
343
345
|
maxToolSteps: session.maxToolSteps,
|
|
344
346
|
autoYes: session.autoYes,
|
package/dist/src/api/models.js
CHANGED
|
@@ -9,10 +9,15 @@ function sanitizeModelInfo(raw) {
|
|
|
9
9
|
const value = raw;
|
|
10
10
|
const id = Number(value.id);
|
|
11
11
|
const label = String(value.label ?? '').trim();
|
|
12
|
-
|
|
12
|
+
const costRating = Number(value.costRating);
|
|
13
|
+
const description = String(value.description ?? '').trim();
|
|
14
|
+
if (!Number.isInteger(id) || id <= 0 || !label || !isCostRating(costRating)) {
|
|
13
15
|
return null;
|
|
14
16
|
}
|
|
15
|
-
return { id, label };
|
|
17
|
+
return { id, label, costRating, description };
|
|
18
|
+
}
|
|
19
|
+
function isCostRating(value) {
|
|
20
|
+
return Number.isInteger(value) && value >= 1 && value <= 3;
|
|
16
21
|
}
|
|
17
22
|
export function getModelsCachePath(env = process.env) {
|
|
18
23
|
return path.join(getClientStateDir(env), 'models.json');
|
package/dist/src/patcher.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import chalk from './colors.js';
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
|
|
2
|
+
import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { createInterface } from 'readline';
|
|
5
5
|
import { runCommand } from './executor.js';
|
|
6
|
+
import { ensureSessionScratchDir, isInsideTheGitAiScratch, isWithinSessionScratchDir, } from './scratch-dir.js';
|
|
6
7
|
import { isTuiMode } from './runtime-mode.js';
|
|
7
8
|
import { truncate } from './utils.js';
|
|
8
9
|
function parseUnifiedDiff(patchText) {
|
|
@@ -143,17 +144,92 @@ export function renderDiffPreview(filePath, patchText) {
|
|
|
143
144
|
function normalizeRoot(rootDir) {
|
|
144
145
|
return path.resolve(rootDir);
|
|
145
146
|
}
|
|
146
|
-
|
|
147
|
+
function expandScratchPath(filePath) {
|
|
148
|
+
const match = filePath.match(/^(?:\$THEGITAI_SCRATCH_DIR|\$\{THEGITAI_SCRATCH_DIR\})(?:[\\/](.*))?$/);
|
|
149
|
+
if (!match)
|
|
150
|
+
return filePath;
|
|
151
|
+
const root = ensureSessionScratchDir();
|
|
152
|
+
return match[1] ? path.join(root, match[1]) : root;
|
|
153
|
+
}
|
|
154
|
+
export function classifyProjectPath(rootDir, filePath) {
|
|
147
155
|
const absRoot = normalizeRoot(rootDir);
|
|
148
|
-
const absPath = path.resolve(absRoot, filePath);
|
|
156
|
+
const absPath = path.resolve(absRoot, expandScratchPath(filePath));
|
|
157
|
+
if (isWithinSessionScratchDir(absPath)) {
|
|
158
|
+
return absPath !== path.resolve(ensureSessionScratchDir()) &&
|
|
159
|
+
isInsideTheGitAiScratch(absPath)
|
|
160
|
+
? 'scratch'
|
|
161
|
+
: 'outside';
|
|
162
|
+
}
|
|
149
163
|
const relative = path.relative(absRoot, absPath);
|
|
150
|
-
if (relative.startsWith('..')
|
|
151
|
-
|
|
164
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
165
|
+
return 'project';
|
|
166
|
+
}
|
|
167
|
+
return 'outside';
|
|
168
|
+
}
|
|
169
|
+
export function resolveProjectPath(rootDir, filePath) {
|
|
170
|
+
const absRoot = normalizeRoot(rootDir);
|
|
171
|
+
const absPath = path.resolve(absRoot, expandScratchPath(filePath));
|
|
172
|
+
if (classifyProjectPath(rootDir, filePath) === 'outside') {
|
|
173
|
+
throw new Error(`Refusing to access path outside the project root: ${filePath}. Allowed locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`);
|
|
152
174
|
}
|
|
153
175
|
return absPath;
|
|
154
176
|
}
|
|
177
|
+
function mkdirForWrite(absPath, scratchPath) {
|
|
178
|
+
const parent = path.dirname(absPath);
|
|
179
|
+
if (!scratchPath) {
|
|
180
|
+
mkdirSync(parent, { recursive: true });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const scratchRoot = path.resolve(ensureSessionScratchDir());
|
|
184
|
+
const relative = path.relative(scratchRoot, parent);
|
|
185
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
186
|
+
throw new Error(`Refusing to create a directory outside the session scratch root: ${parent}`);
|
|
187
|
+
}
|
|
188
|
+
let current = scratchRoot;
|
|
189
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
190
|
+
current = path.join(current, segment);
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(current, { mode: 0o700 });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error?.code !== 'EEXIST')
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const stat = lstatSync(current);
|
|
199
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
200
|
+
throw new Error(`Refusing to traverse unsafe scratch directory: ${current}`);
|
|
201
|
+
}
|
|
202
|
+
if (process.platform !== 'win32')
|
|
203
|
+
chmodSync(current, 0o700);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function writeScratchFile(absPath, content) {
|
|
207
|
+
const scratchRoot = realpathSync(ensureSessionScratchDir());
|
|
208
|
+
const parent = realpathSync(path.dirname(absPath));
|
|
209
|
+
const relativeParent = path.relative(scratchRoot, parent);
|
|
210
|
+
if (relativeParent.startsWith('..') || path.isAbsolute(relativeParent)) {
|
|
211
|
+
throw new Error(`Refusing to write through an unsafe scratch directory: ${absPath}`);
|
|
212
|
+
}
|
|
213
|
+
const verifiedPath = path.join(parent, path.basename(absPath));
|
|
214
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
215
|
+
const fd = openSync(verifiedPath, constants.O_WRONLY | constants.O_CREAT | noFollow, 0o600);
|
|
216
|
+
try {
|
|
217
|
+
const stat = fstatSync(fd);
|
|
218
|
+
if (!stat.isFile() || stat.nlink > 1) {
|
|
219
|
+
throw new Error(`Refusing to write an unsafe scratch file: ${absPath}`);
|
|
220
|
+
}
|
|
221
|
+
ftruncateSync(fd, 0);
|
|
222
|
+
if (process.platform !== 'win32')
|
|
223
|
+
fchmodSync(fd, 0o600);
|
|
224
|
+
writeFileSync(fd, content);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
closeSync(fd);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
155
230
|
export function writeProjectFile(rootDir, filePath, content) {
|
|
156
231
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
232
|
+
const scratchPath = classifyProjectPath(rootDir, filePath) === 'scratch';
|
|
157
233
|
if (existsSync(absPath)) {
|
|
158
234
|
try {
|
|
159
235
|
const existingContent = readFileSync(absPath, 'utf-8');
|
|
@@ -164,12 +240,18 @@ export function writeProjectFile(rootDir, filePath, content) {
|
|
|
164
240
|
catch {
|
|
165
241
|
}
|
|
166
242
|
}
|
|
167
|
-
|
|
168
|
-
|
|
243
|
+
mkdirForWrite(absPath, scratchPath);
|
|
244
|
+
if (scratchPath) {
|
|
245
|
+
writeScratchFile(absPath, content);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
writeFileSync(absPath, content, 'utf-8');
|
|
249
|
+
}
|
|
169
250
|
return { absPath, changed: true };
|
|
170
251
|
}
|
|
171
252
|
export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
172
253
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
254
|
+
const scratchPath = classifyProjectPath(rootDir, filePath) === 'scratch';
|
|
173
255
|
if (existsSync(absPath)) {
|
|
174
256
|
try {
|
|
175
257
|
const existingContent = readFileSync(absPath);
|
|
@@ -180,8 +262,13 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
|
180
262
|
catch {
|
|
181
263
|
}
|
|
182
264
|
}
|
|
183
|
-
|
|
184
|
-
|
|
265
|
+
mkdirForWrite(absPath, scratchPath);
|
|
266
|
+
if (scratchPath) {
|
|
267
|
+
writeScratchFile(absPath, content);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
writeFileSync(absPath, content);
|
|
271
|
+
}
|
|
185
272
|
return { absPath, changed: true };
|
|
186
273
|
}
|
|
187
274
|
export function deleteProjectFile(rootDir, filePath) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { shouldIgnoreArtifactPath } from './artifact-policy.js';
|
|
4
|
+
const MAX_TOP_LEVEL_ENTRIES = 80;
|
|
5
|
+
const MAX_DECLARED_TASKS = 40;
|
|
6
|
+
const MAX_NAME_CHARS = 120;
|
|
7
|
+
const MAX_PACKAGE_JSON_BYTES = 1024 * 1024;
|
|
8
|
+
function normalizeName(value) {
|
|
9
|
+
if (typeof value !== 'string')
|
|
10
|
+
return null;
|
|
11
|
+
const normalized = value
|
|
12
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim()
|
|
15
|
+
.slice(0, MAX_NAME_CHARS);
|
|
16
|
+
return normalized || null;
|
|
17
|
+
}
|
|
18
|
+
function normalizeNames(value, limit) {
|
|
19
|
+
if (!Array.isArray(value))
|
|
20
|
+
return [];
|
|
21
|
+
const names = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const item of value) {
|
|
24
|
+
const name = normalizeName(item);
|
|
25
|
+
if (!name || seen.has(name))
|
|
26
|
+
continue;
|
|
27
|
+
seen.add(name);
|
|
28
|
+
names.push(name);
|
|
29
|
+
if (names.length >= limit)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
function readPackageTasks(rootDir) {
|
|
35
|
+
const packagePath = path.join(rootDir, 'package.json');
|
|
36
|
+
let fd = null;
|
|
37
|
+
try {
|
|
38
|
+
const pathStat = lstatSync(packagePath);
|
|
39
|
+
if (pathStat.isSymbolicLink() ||
|
|
40
|
+
!pathStat.isFile() ||
|
|
41
|
+
pathStat.nlink > 1 ||
|
|
42
|
+
pathStat.size > MAX_PACKAGE_JSON_BYTES) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
46
|
+
fd = openSync(packagePath, constants.O_RDONLY | noFollow);
|
|
47
|
+
const fileStat = fstatSync(fd);
|
|
48
|
+
if (!fileStat.isFile() ||
|
|
49
|
+
fileStat.nlink > 1 ||
|
|
50
|
+
fileStat.size > MAX_PACKAGE_JSON_BYTES ||
|
|
51
|
+
fileStat.dev !== pathStat.dev ||
|
|
52
|
+
fileStat.ino !== pathStat.ino) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const parsed = JSON.parse(readFileSync(fd, 'utf8'));
|
|
56
|
+
if (!parsed?.scripts || typeof parsed.scripts !== 'object')
|
|
57
|
+
return [];
|
|
58
|
+
return normalizeNames(Object.keys(parsed.scripts), MAX_DECLARED_TASKS);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (fd !== null)
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function normalizeProjectOrientation(value) {
|
|
69
|
+
if (!value || typeof value !== 'object')
|
|
70
|
+
return null;
|
|
71
|
+
const record = value;
|
|
72
|
+
const topLevelEntries = normalizeNames(record.topLevelEntries, MAX_TOP_LEVEL_ENTRIES);
|
|
73
|
+
const packageScripts = normalizeNames(record.packageScripts, MAX_DECLARED_TASKS);
|
|
74
|
+
if (topLevelEntries.length === 0 && packageScripts.length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
return {
|
|
77
|
+
topLevelEntries,
|
|
78
|
+
packageScripts,
|
|
79
|
+
truncated: record.truncated === true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function collectProjectOrientation(rootDir) {
|
|
83
|
+
try {
|
|
84
|
+
const entries = readdirSync(rootDir, { withFileTypes: true })
|
|
85
|
+
.filter((entry) => !shouldIgnoreArtifactPath(entry.name))
|
|
86
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
87
|
+
const topLevelEntries = entries
|
|
88
|
+
.slice(0, MAX_TOP_LEVEL_ENTRIES)
|
|
89
|
+
.map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`);
|
|
90
|
+
return normalizeProjectOrientation({
|
|
91
|
+
topLevelEntries,
|
|
92
|
+
packageScripts: readPackageTasks(rootDir),
|
|
93
|
+
truncated: entries.length > MAX_TOP_LEVEL_ENTRIES,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/src/scratch-dir.js
CHANGED
|
@@ -1,57 +1,75 @@
|
|
|
1
|
-
import { chmodSync, lstatSync,
|
|
1
|
+
import { chmodSync, lstatSync, mkdtempSync } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
4
|
+
const scratchDirs = new Map();
|
|
5
|
+
let activeSessionId = 'default';
|
|
6
|
+
export function setScratchSession(sessionId) {
|
|
7
|
+
activeSessionId = String(sessionId ?? '').trim() || 'default';
|
|
8
|
+
}
|
|
9
|
+
function allocateSessionScratchDir() {
|
|
10
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
|
|
11
|
+
scratchDirs.set(activeSessionId, dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
5
14
|
export function sessionScratchDir() {
|
|
6
|
-
|
|
7
|
-
cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
|
|
8
|
-
}
|
|
9
|
-
return cachedScratchDir;
|
|
15
|
+
return scratchDirs.get(activeSessionId) ?? allocateSessionScratchDir();
|
|
10
16
|
}
|
|
11
|
-
function
|
|
17
|
+
function isOwnedDirectory(dir) {
|
|
12
18
|
try {
|
|
13
19
|
const st = lstatSync(dir);
|
|
14
20
|
if (st.isSymbolicLink() || !st.isDirectory())
|
|
15
|
-
return
|
|
21
|
+
return false;
|
|
16
22
|
if (process.platform !== 'win32' &&
|
|
17
23
|
typeof process.getuid === 'function' &&
|
|
18
24
|
st.uid !== process.getuid()) {
|
|
19
|
-
return
|
|
25
|
+
return false;
|
|
20
26
|
}
|
|
21
|
-
return
|
|
27
|
+
return true;
|
|
22
28
|
}
|
|
23
29
|
catch {
|
|
24
30
|
return false;
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
33
|
export function ensureSessionScratchDir() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
let dir = sessionScratchDir();
|
|
35
|
+
if (!isOwnedDirectory(dir)) {
|
|
36
|
+
dir = allocateSessionScratchDir();
|
|
37
|
+
}
|
|
38
|
+
if (process.platform !== 'win32') {
|
|
39
|
+
chmodSync(dir, 0o700);
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
function hasUnsafeScratchComponent(root, relativePath) {
|
|
44
|
+
let current = root;
|
|
45
|
+
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
|
|
46
|
+
current = path.join(current, segment);
|
|
47
|
+
try {
|
|
48
|
+
const stat = lstatSync(current);
|
|
49
|
+
if (stat.isSymbolicLink())
|
|
50
|
+
return true;
|
|
51
|
+
if (stat.isDirectory())
|
|
52
|
+
continue;
|
|
53
|
+
if (!stat.isFile() || stat.nlink > 1)
|
|
54
|
+
return true;
|
|
33
55
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
chmodSync(dir, 0o700);
|
|
56
|
+
catch (error) {
|
|
57
|
+
return error?.code !== 'ENOENT';
|
|
37
58
|
}
|
|
38
59
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
export function isWithinSessionScratchDir(absPath) {
|
|
63
|
+
const root = path.resolve(ensureSessionScratchDir());
|
|
64
|
+
const resolved = path.resolve(absPath);
|
|
65
|
+
const relative = path.relative(root, resolved);
|
|
66
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
42
67
|
}
|
|
43
68
|
export function isInsideTheGitAiScratch(absPath) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
const first = relPath.split(/[\\/]/, 1)[0] ?? '';
|
|
50
|
-
if (!/^thegitai(?:$|[-.])/i.test(first)) {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
if (/[*?[\]{}]/.test(first)) {
|
|
69
|
+
const root = path.resolve(ensureSessionScratchDir());
|
|
70
|
+
const resolved = path.resolve(absPath);
|
|
71
|
+
const relative = path.relative(root, resolved);
|
|
72
|
+
if (!isWithinSessionScratchDir(resolved))
|
|
54
73
|
return false;
|
|
55
|
-
|
|
56
|
-
return !isSquattedScratchRoot(path.join(tempRoot, first));
|
|
74
|
+
return !hasUnsafeScratchComponent(root, relative);
|
|
57
75
|
}
|
|
@@ -2,6 +2,7 @@ import { drainBackgroundJobNotifications, getBackgroundJob, } from './background
|
|
|
2
2
|
import { canStoreEditSnapshot, isEditToolName, isGitWorkTree, MAX_EDIT_JOURNAL_RECORDS, operationFromSnapshots, readFileEditSnapshot, } from './edit-journal.js';
|
|
3
3
|
import { clearEditFailure, collectCommandMutations, captureMutationBaseline, ensureActiveCheckpoint, recordEditFailure, recordSessionEdit, rememberCheckpointFiles, } from './session-safety.js';
|
|
4
4
|
import { buildAgentModeToolBlockedResult, } from './agent-mode.js';
|
|
5
|
+
import { classifyProjectPath } from './patcher.js';
|
|
5
6
|
import { dispatchTool } from './tools/index.js';
|
|
6
7
|
import { syncIndexFromDisk } from './project-index.js';
|
|
7
8
|
import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
|
|
@@ -84,9 +85,38 @@ async function collectTrackedCommandMutations({ session, projectIndex, result, t
|
|
|
84
85
|
invalidateShellDiagnosticsCache(session.rootDir);
|
|
85
86
|
rememberCheckpointFiles(session.clientState.safety, session.rootDir, records.map((record) => record.filePath), turnId);
|
|
86
87
|
const priorSync = result.repoSync;
|
|
87
|
-
if (!(priorSync &&
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
if (!(priorSync && (priorSync.added || priorSync.modified || priorSync.removed))) {
|
|
89
|
+
const mutationCounts = {
|
|
90
|
+
added: records.filter((record) => record.operation === 'create').length,
|
|
91
|
+
modified: records.filter((record) => record.operation === 'update').length,
|
|
92
|
+
removed: records.filter((record) => record.operation === 'delete').length,
|
|
93
|
+
indexedChunks: 0,
|
|
94
|
+
retrievalTokensUsed: 0,
|
|
95
|
+
};
|
|
96
|
+
if (priorSync?.error) {
|
|
97
|
+
result.repoSync = {
|
|
98
|
+
...mutationCounts,
|
|
99
|
+
indexSyncError: priorSync.error,
|
|
100
|
+
skipped: true,
|
|
101
|
+
reason: 'local index sync failed after command execution',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
try {
|
|
106
|
+
const repoSync = await syncIndexFromDisk(projectIndex);
|
|
107
|
+
result.repoSync = {
|
|
108
|
+
...repoSync,
|
|
109
|
+
added: Math.max(repoSync.added, mutationCounts.added),
|
|
110
|
+
modified: Math.max(repoSync.modified, mutationCounts.modified),
|
|
111
|
+
removed: Math.max(repoSync.removed, mutationCounts.removed),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const indexSyncError = error instanceof Error ? error.message : String(error);
|
|
116
|
+
result.repoSync = { ...mutationCounts, indexSyncError };
|
|
117
|
+
session.onStatus(`Repository changes were recorded, but local index sync failed: ${indexSyncError}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
90
120
|
}
|
|
91
121
|
result.sessionEdits = records.map((record) => ({
|
|
92
122
|
id: record.id,
|
|
@@ -150,7 +180,7 @@ export async function collectBackgroundJobUiOutputMutations({ session, projectIn
|
|
|
150
180
|
}
|
|
151
181
|
}
|
|
152
182
|
function recordAssistantEdit(session, call, result, before) {
|
|
153
|
-
if (!before || !isEditToolName(call.name))
|
|
183
|
+
if (!before || !isEditToolName(call.name) || result?.scratch === true)
|
|
154
184
|
return;
|
|
155
185
|
if (!result || typeof result !== 'object' || result.ok !== true) {
|
|
156
186
|
const filePath = getEditToolFilePath(call);
|
|
@@ -231,10 +261,12 @@ export async function executeLocalToolCall(toolContext, session, call) {
|
|
|
231
261
|
!editToolWritesSeparateOutput(call)
|
|
232
262
|
? repairFilePath(session.rootDir, rawEditFilePath)
|
|
233
263
|
: rawEditFilePath;
|
|
234
|
-
|
|
264
|
+
const tracksRepositoryEdit = filePathBeforeEdit &&
|
|
265
|
+
classifyProjectPath(session.rootDir, filePathBeforeEdit) === 'project';
|
|
266
|
+
if (tracksRepositoryEdit) {
|
|
235
267
|
rememberCheckpointFiles(session.clientState.safety, session.rootDir, [filePathBeforeEdit], session.turnState.id);
|
|
236
268
|
}
|
|
237
|
-
const beforeEditSnapshot =
|
|
269
|
+
const beforeEditSnapshot = tracksRepositoryEdit
|
|
238
270
|
? readFileEditSnapshot(session.rootDir, filePathBeforeEdit)
|
|
239
271
|
: null;
|
|
240
272
|
const commandTracker = call.name === 'run_command' || call.name === 'run_node_script'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
|
-
import { deleteProjectFile } from '../patcher.js';
|
|
2
|
+
import { classifyProjectPath, deleteProjectFile } from '../patcher.js';
|
|
3
3
|
import { isTuiMode } from '../runtime-mode.js';
|
|
4
4
|
import { removeIndexFile } from '../project-index.js';
|
|
5
5
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
@@ -9,10 +9,27 @@ export async function deleteFile(context, args) {
|
|
|
9
9
|
if (!filePath) {
|
|
10
10
|
return { ok: false, error: 'filePath is required' };
|
|
11
11
|
}
|
|
12
|
+
const pathKind = classifyProjectPath(rootDir, filePath);
|
|
13
|
+
if (pathKind === 'outside') {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
filePath,
|
|
17
|
+
error: `Refusing to delete outside the project root: ${filePath}. Deletable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
|
|
18
|
+
failureCategory: 'invalid_argument',
|
|
19
|
+
failureDetails: {
|
|
20
|
+
category: 'invalid_argument',
|
|
21
|
+
tool: 'delete_file',
|
|
22
|
+
action: 'Delete files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR).',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const scratchPath = pathKind === 'scratch';
|
|
12
27
|
const result = deleteProjectFile(rootDir, filePath);
|
|
13
28
|
if (result.deleted) {
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
if (!scratchPath) {
|
|
30
|
+
await removeIndexFile(projectIndex, filePath);
|
|
31
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
32
|
+
}
|
|
16
33
|
if (!isTuiMode())
|
|
17
34
|
console.log(chalk.red(` 🗑️ Deleted: ${filePath}`));
|
|
18
35
|
}
|
|
@@ -21,7 +38,8 @@ export async function deleteFile(context, args) {
|
|
|
21
38
|
filePath,
|
|
22
39
|
changed: result.deleted,
|
|
23
40
|
deleted: result.deleted,
|
|
41
|
+
...(scratchPath ? { scratch: true } : {}),
|
|
24
42
|
content: result.content,
|
|
25
|
-
diagnostics: result.deleted ? runShellDiagnostics(rootDir) : undefined,
|
|
43
|
+
diagnostics: result.deleted && !scratchPath ? runShellDiagnostics(rootDir) : undefined,
|
|
26
44
|
};
|
|
27
45
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
4
|
-
import { applyUnifiedPatch, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
|
|
4
|
+
import { applyUnifiedPatch, classifyProjectPath, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
|
|
5
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
6
6
|
import { repairFilePath } from './path-suggest.js';
|
|
7
7
|
import { isTuiMode } from '../runtime-mode.js';
|
|
@@ -29,6 +29,21 @@ export async function patchFile(context, args) {
|
|
|
29
29
|
failureCategory: 'invalid_argument',
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
const pathKind = classifyProjectPath(rootDir, filePath);
|
|
33
|
+
if (pathKind === 'outside') {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
filePath,
|
|
37
|
+
error: `Refusing to edit outside the project root: ${filePath}. Editable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
|
|
38
|
+
failureCategory: 'invalid_argument',
|
|
39
|
+
failureDetails: {
|
|
40
|
+
category: 'invalid_argument',
|
|
41
|
+
tool: 'patch_file',
|
|
42
|
+
action: 'Edit files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR) for temporary files.',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const scratchPath = pathKind === 'scratch';
|
|
32
47
|
let originalContent;
|
|
33
48
|
try {
|
|
34
49
|
originalContent = readProjectFile(rootDir, filePath);
|
|
@@ -68,20 +83,29 @@ export async function patchFile(context, args) {
|
|
|
68
83
|
ok: false,
|
|
69
84
|
skipped: true,
|
|
70
85
|
filePath,
|
|
71
|
-
|
|
86
|
+
failureCategory: 'user_declined',
|
|
87
|
+
failureDetails: {
|
|
88
|
+
category: 'user_declined',
|
|
89
|
+
tool: 'patch_file',
|
|
90
|
+
action: 'Respect the real user’s decision. Do not retry the same or an equivalent edit; reconsider the approach or ask one specific question if needed.',
|
|
91
|
+
},
|
|
92
|
+
error: 'The real user rejected this proposed patch. Nothing was changed; this was not a tool failure or an automated system skip.',
|
|
72
93
|
};
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
96
|
const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
|
|
76
97
|
let indexedChunks = 0;
|
|
77
98
|
let retrievalTokensUsed = 0;
|
|
78
|
-
if (changed) {
|
|
99
|
+
if (changed && !scratchPath) {
|
|
79
100
|
const indexResult = await upsertIndexFile(projectIndex, filePath);
|
|
80
101
|
indexedChunks = indexResult.indexedChunks;
|
|
81
102
|
retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
|
|
82
103
|
}
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
let diagnostics;
|
|
105
|
+
if (!scratchPath) {
|
|
106
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
107
|
+
diagnostics = runShellDiagnostics(rootDir, filePath);
|
|
108
|
+
}
|
|
85
109
|
const originalLines = originalContent.split('\n').length;
|
|
86
110
|
const patchedLines = patchedContent.split('\n').length;
|
|
87
111
|
if (!isTuiMode()) {
|
|
@@ -94,6 +118,7 @@ export async function patchFile(context, args) {
|
|
|
94
118
|
filePath,
|
|
95
119
|
changed,
|
|
96
120
|
operation: 'patch',
|
|
121
|
+
...(scratchPath ? { scratch: true } : {}),
|
|
97
122
|
indexedChunks,
|
|
98
123
|
retrievalTokensUsed,
|
|
99
124
|
bytesWritten: Buffer.byteLength(patchedContent, 'utf-8'),
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
|
|
4
4
|
import { buildSecretFilePreview, isDotenvLikePath, looksLikeEditableDotenv, shouldUseSecretFilePreview, } from '../secret-preview.js';
|
|
5
5
|
import { readProjectFile } from '../patcher.js';
|
|
6
|
+
import { isWithinSessionScratchDir } from '../scratch-dir.js';
|
|
6
7
|
import { repairFilePath } from './path-suggest.js';
|
|
7
8
|
import { dotenvFitsRedactionBudget, getCurrentFileHash, recordReadCoverage, redactContentWithStableTokens, redactDotenvWithStableTokens, } from '../session-safety.js';
|
|
8
9
|
import { readFileRange, truncate } from '../utils.js';
|
|
@@ -16,6 +17,7 @@ export async function readFile(context, args) {
|
|
|
16
17
|
return { ok: false, error: 'filePath is required' };
|
|
17
18
|
}
|
|
18
19
|
const projectPath = normalizeProjectRelativePath(rootDir, filePath);
|
|
20
|
+
const scratchPath = path.isAbsolute(filePath) && isWithinSessionScratchDir(filePath);
|
|
19
21
|
if (!projectPath && !path.isAbsolute(filePath)) {
|
|
20
22
|
return {
|
|
21
23
|
ok: false,
|
|
@@ -37,7 +39,7 @@ export async function readFile(context, args) {
|
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
let content;
|
|
40
|
-
if (projectPath) {
|
|
42
|
+
if (projectPath || scratchPath) {
|
|
41
43
|
try {
|
|
42
44
|
content = readProjectFile(rootDir, filePath);
|
|
43
45
|
}
|
|
@@ -188,7 +188,13 @@ export async function replaceDocumentText(context, args) {
|
|
|
188
188
|
ok: false,
|
|
189
189
|
skipped: true,
|
|
190
190
|
filePath: targetPath,
|
|
191
|
-
|
|
191
|
+
failureCategory: 'user_declined',
|
|
192
|
+
failureDetails: {
|
|
193
|
+
category: 'user_declined',
|
|
194
|
+
tool: 'replace_document_text',
|
|
195
|
+
action: 'Respect the real user’s decision. Do not retry the same or an equivalent edit; reconsider the approach or ask one specific question if needed.',
|
|
196
|
+
},
|
|
197
|
+
error: 'The real user rejected this proposed document edit. Nothing was changed; this was not a tool failure or an automated system skip.',
|
|
192
198
|
};
|
|
193
199
|
}
|
|
194
200
|
}
|
|
@@ -49,7 +49,13 @@ export async function runShellCommand(context, args) {
|
|
|
49
49
|
ok: false,
|
|
50
50
|
skipped: true,
|
|
51
51
|
command,
|
|
52
|
-
|
|
52
|
+
failureCategory: 'user_declined',
|
|
53
|
+
failureDetails: {
|
|
54
|
+
category: 'user_declined',
|
|
55
|
+
tool: 'run_command',
|
|
56
|
+
action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
|
|
57
|
+
},
|
|
58
|
+
error: 'The real user rejected this proposed command. Nothing was executed; this was not a tool failure or an automated system skip.',
|
|
53
59
|
};
|
|
54
60
|
}
|
|
55
61
|
}
|
|
@@ -60,15 +66,7 @@ export async function runShellCommand(context, args) {
|
|
|
60
66
|
requestSudoPassword,
|
|
61
67
|
timeout: typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : undefined,
|
|
62
68
|
});
|
|
63
|
-
const repoSync = projectIndex
|
|
64
|
-
? await syncIndexFromDisk(projectIndex)
|
|
65
|
-
: {
|
|
66
|
-
added: 0,
|
|
67
|
-
modified: 0,
|
|
68
|
-
removed: 0,
|
|
69
|
-
indexedChunks: 0,
|
|
70
|
-
retrievalTokensUsed: 0,
|
|
71
|
-
};
|
|
69
|
+
const repoSync = await syncRepoIndex(projectIndex, onStatus);
|
|
72
70
|
invalidateShellDiagnosticsCache(rootDir);
|
|
73
71
|
const diagnostics = buildDeferredShellDiagnostics('run_command');
|
|
74
72
|
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
@@ -98,6 +96,34 @@ export function boundCommandOutput(output) {
|
|
|
98
96
|
`\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
|
|
99
97
|
output.slice(-tailSize));
|
|
100
98
|
}
|
|
99
|
+
async function syncRepoIndex(projectIndex, onStatus) {
|
|
100
|
+
if (!projectIndex.initialized) {
|
|
101
|
+
return {
|
|
102
|
+
added: 0,
|
|
103
|
+
modified: 0,
|
|
104
|
+
removed: 0,
|
|
105
|
+
indexedChunks: 0,
|
|
106
|
+
retrievalTokensUsed: 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return await syncIndexFromDisk(projectIndex);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
onStatus(`Command completed, but local index sync failed: ${message}`);
|
|
115
|
+
return {
|
|
116
|
+
added: 0,
|
|
117
|
+
modified: 0,
|
|
118
|
+
removed: 0,
|
|
119
|
+
indexedChunks: 0,
|
|
120
|
+
retrievalTokensUsed: 0,
|
|
121
|
+
skipped: true,
|
|
122
|
+
reason: 'local index sync failed after command execution',
|
|
123
|
+
error: message,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
101
127
|
async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
|
|
102
128
|
const { rootDir, projectIndex, onStatus } = context;
|
|
103
129
|
const started = await startBackgroundJob(command, rootDir, {
|
|
@@ -116,15 +142,7 @@ async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
|
|
|
116
142
|
};
|
|
117
143
|
}
|
|
118
144
|
const snapshot = started.snapshot;
|
|
119
|
-
const repoSync = projectIndex
|
|
120
|
-
? await syncIndexFromDisk(projectIndex)
|
|
121
|
-
: {
|
|
122
|
-
added: 0,
|
|
123
|
-
modified: 0,
|
|
124
|
-
removed: 0,
|
|
125
|
-
indexedChunks: 0,
|
|
126
|
-
retrievalTokensUsed: 0,
|
|
127
|
-
};
|
|
145
|
+
const repoSync = await syncRepoIndex(projectIndex, onStatus);
|
|
128
146
|
invalidateShellDiagnosticsCache(rootDir);
|
|
129
147
|
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
130
148
|
onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
@@ -175,7 +175,13 @@ export async function runNodeScript(context, args) {
|
|
|
175
175
|
ok: false,
|
|
176
176
|
skipped: true,
|
|
177
177
|
command: COMMAND_LABEL,
|
|
178
|
-
|
|
178
|
+
failureCategory: 'user_declined',
|
|
179
|
+
failureDetails: {
|
|
180
|
+
category: 'user_declined',
|
|
181
|
+
tool: 'run_node_script',
|
|
182
|
+
action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
|
|
183
|
+
},
|
|
184
|
+
error: 'The real user rejected this proposed script. Nothing was executed; this was not a tool failure or an automated system skip.',
|
|
179
185
|
};
|
|
180
186
|
}
|
|
181
187
|
}
|
|
@@ -186,13 +192,27 @@ export async function runNodeScript(context, args) {
|
|
|
186
192
|
const afterGitStatus = readGitStatusSignature(rootDir);
|
|
187
193
|
const gitStatusCleanBeforeAndAfter = beforeGitStatus === '' && afterGitStatus === '';
|
|
188
194
|
const shouldSync = context.projectIndex.initialized && !gitStatusCleanBeforeAndAfter;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
let repoSync;
|
|
196
|
+
if (shouldSync) {
|
|
197
|
+
try {
|
|
198
|
+
repoSync = await syncIndexFromDisk(context.projectIndex);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
context.onStatus(`Node script completed, but local index sync failed: ${message}`);
|
|
203
|
+
repoSync = {
|
|
204
|
+
...emptyRepoSync('local index sync failed after Node script execution'),
|
|
205
|
+
error: message,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
repoSync = emptyRepoSync(!context.projectIndex.initialized
|
|
192
211
|
? 'project index not initialized'
|
|
193
212
|
: gitStatusCleanBeforeAndAfter
|
|
194
213
|
? 'git status clean before and after'
|
|
195
214
|
: 'sync not needed');
|
|
215
|
+
}
|
|
196
216
|
invalidateShellDiagnosticsCache(rootDir);
|
|
197
217
|
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
198
218
|
context.onStatus(`Synced repo state after Node script (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
4
|
-
import { readProjectFile, writeProjectFile } from '../patcher.js';
|
|
4
|
+
import { classifyProjectPath, readProjectFile, writeProjectFile, } from '../patcher.js';
|
|
5
5
|
import { repairFilePath } from './path-suggest.js';
|
|
6
6
|
import { upsertIndexFile } from '../project-index.js';
|
|
7
7
|
import { isTuiMode } from '../runtime-mode.js';
|
|
@@ -106,6 +106,21 @@ export async function strReplace(context, args) {
|
|
|
106
106
|
failureCategory: 'invalid_argument',
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
|
+
const pathKind = classifyProjectPath(rootDir, filePath);
|
|
110
|
+
if (pathKind === 'outside') {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
filePath,
|
|
114
|
+
error: `Refusing to edit outside the project root: ${filePath}. Editable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
|
|
115
|
+
failureCategory: 'invalid_argument',
|
|
116
|
+
failureDetails: {
|
|
117
|
+
category: 'invalid_argument',
|
|
118
|
+
tool: 'str_replace',
|
|
119
|
+
action: 'Edit files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR) for temporary files.',
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const scratchPath = pathKind === 'scratch';
|
|
109
124
|
let originalContent;
|
|
110
125
|
try {
|
|
111
126
|
originalContent = readProjectFile(rootDir, filePath);
|
|
@@ -169,7 +184,13 @@ export async function strReplace(context, args) {
|
|
|
169
184
|
ok: false,
|
|
170
185
|
skipped: true,
|
|
171
186
|
filePath,
|
|
172
|
-
|
|
187
|
+
failureCategory: 'user_declined',
|
|
188
|
+
failureDetails: {
|
|
189
|
+
category: 'user_declined',
|
|
190
|
+
tool: 'str_replace',
|
|
191
|
+
action: 'Respect the real user’s decision. Do not retry the same or an equivalent edit; reconsider the approach or ask one specific question if needed.',
|
|
192
|
+
},
|
|
193
|
+
error: 'The real user rejected this proposed edit. Nothing was changed; this was not a tool failure or an automated system skip.',
|
|
173
194
|
};
|
|
174
195
|
}
|
|
175
196
|
}
|
|
@@ -178,13 +199,16 @@ export async function strReplace(context, args) {
|
|
|
178
199
|
const replacements = changed ? (replaceAll ? n : 1) : 0;
|
|
179
200
|
let indexedChunks = 0;
|
|
180
201
|
let retrievalTokensUsed = 0;
|
|
181
|
-
if (changed) {
|
|
202
|
+
if (changed && !scratchPath) {
|
|
182
203
|
const indexResult = await upsertIndexFile(projectIndex, filePath);
|
|
183
204
|
indexedChunks = indexResult.indexedChunks;
|
|
184
205
|
retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
|
|
185
206
|
}
|
|
186
|
-
|
|
187
|
-
|
|
207
|
+
let diagnostics;
|
|
208
|
+
if (!scratchPath) {
|
|
209
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
210
|
+
diagnostics = runShellDiagnostics(rootDir, filePath);
|
|
211
|
+
}
|
|
188
212
|
const originalLines = originalContent.split('\n').length;
|
|
189
213
|
const nextLines = nextContent.split('\n').length;
|
|
190
214
|
if (!isTuiMode()) {
|
|
@@ -197,6 +221,7 @@ export async function strReplace(context, args) {
|
|
|
197
221
|
filePath,
|
|
198
222
|
changed,
|
|
199
223
|
operation: 'str_replace',
|
|
224
|
+
...(scratchPath ? { scratch: true } : {}),
|
|
200
225
|
replacements,
|
|
201
226
|
indexedChunks,
|
|
202
227
|
retrievalTokensUsed,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
4
|
-
import { writeProjectFile } from '../patcher.js';
|
|
4
|
+
import { classifyProjectPath, writeProjectFile } from '../patcher.js';
|
|
5
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
6
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
7
7
|
import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
|
|
@@ -25,9 +25,25 @@ export async function writeFile(context, args) {
|
|
|
25
25
|
failureCategory: 'invalid_argument',
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
+
const pathKind = classifyProjectPath(rootDir, filePath);
|
|
29
|
+
if (pathKind === 'outside') {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
filePath,
|
|
33
|
+
error: `Refusing to write outside the project root: ${filePath}. Writable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
|
|
34
|
+
failureCategory: 'invalid_argument',
|
|
35
|
+
failureDetails: {
|
|
36
|
+
category: 'invalid_argument',
|
|
37
|
+
tool: 'write_file',
|
|
38
|
+
action: 'Write inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR) for temporary files.',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const scratchPath = pathKind === 'scratch';
|
|
28
43
|
const coveragePath = normalizeProjectRelativePath(rootDir, filePath) ?? filePath;
|
|
29
44
|
const currentHash = getCurrentFileHash(rootDir, filePath);
|
|
30
|
-
if (
|
|
45
|
+
if (!scratchPath &&
|
|
46
|
+
currentHash !== null &&
|
|
31
47
|
context.safety &&
|
|
32
48
|
!hasFreshFullReadCoverage(context.safety, coveragePath, currentHash)) {
|
|
33
49
|
return {
|
|
@@ -47,13 +63,16 @@ export async function writeFile(context, args) {
|
|
|
47
63
|
const { changed } = writeProjectFile(rootDir, filePath, content);
|
|
48
64
|
let indexedChunks = 0;
|
|
49
65
|
let retrievalTokensUsed = 0;
|
|
50
|
-
if (changed) {
|
|
66
|
+
if (changed && !scratchPath) {
|
|
51
67
|
const indexResult = await upsertIndexFile(projectIndex, filePath);
|
|
52
68
|
indexedChunks = indexResult.indexedChunks;
|
|
53
69
|
retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
|
|
54
70
|
}
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
let diagnostics;
|
|
72
|
+
if (!scratchPath) {
|
|
73
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
74
|
+
diagnostics = runShellDiagnostics(rootDir, filePath);
|
|
75
|
+
}
|
|
57
76
|
if (!isTuiMode()) {
|
|
58
77
|
const icon = changed ? '✨' : '📝';
|
|
59
78
|
const label = changed ? 'Created/Updated' : 'Created/Updated (no change)';
|
|
@@ -64,6 +83,7 @@ export async function writeFile(context, args) {
|
|
|
64
83
|
filePath,
|
|
65
84
|
changed,
|
|
66
85
|
operation: 'write',
|
|
86
|
+
...(scratchPath ? { scratch: true } : {}),
|
|
67
87
|
indexedChunks,
|
|
68
88
|
retrievalTokensUsed,
|
|
69
89
|
bytesWritten: Buffer.byteLength(content, 'utf-8'),
|
package/dist/src/ui/repl.js
CHANGED
|
@@ -7,6 +7,7 @@ import { chat, models } from '../api/index.js';
|
|
|
7
7
|
import { isTurnCancelledError } from '../api/chat.js';
|
|
8
8
|
import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
|
|
9
9
|
import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
|
|
10
|
+
import { setScratchSession } from '../scratch-dir.js';
|
|
10
11
|
import { cancelActiveCommand } from '../executor.js';
|
|
11
12
|
import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
|
|
12
13
|
import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
|
|
@@ -558,6 +559,31 @@ function buildFileChangeEntry(event) {
|
|
|
558
559
|
title: skipped ? `${verb} skipped: ${filePath}` : `${verb} failed: ${filePath}`,
|
|
559
560
|
};
|
|
560
561
|
}
|
|
562
|
+
if (result?.scratch === true) {
|
|
563
|
+
const scratchName = truncate(filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? filePath, 72);
|
|
564
|
+
if (call.name === 'delete_file') {
|
|
565
|
+
return {
|
|
566
|
+
body: '',
|
|
567
|
+
filePath,
|
|
568
|
+
kind: 'tool',
|
|
569
|
+
title: result?.deleted === true
|
|
570
|
+
? `Removed scratch file: ${scratchName}`
|
|
571
|
+
: `Scratch delete skipped: ${scratchName}`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
const content = typeof call.args?.content === 'string' ? call.args.content : '';
|
|
575
|
+
const lineSummary = call.name === 'write_file' && content
|
|
576
|
+
? ` (${splitDiffLines(content).length} lines)`
|
|
577
|
+
: '';
|
|
578
|
+
return {
|
|
579
|
+
body: '',
|
|
580
|
+
filePath,
|
|
581
|
+
kind: 'tool',
|
|
582
|
+
title: call.name === 'write_file'
|
|
583
|
+
? `Wrote scratch file: ${scratchName}${lineSummary}`
|
|
584
|
+
: `Edited scratch file: ${scratchName}`,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
561
587
|
if (call.name === 'undo_edit') {
|
|
562
588
|
const dryRun = result?.dryRun === true ||
|
|
563
589
|
result?.dry_run === true ||
|
|
@@ -1212,7 +1238,11 @@ export function buildModelPickerOptions(currentModelId, serverModels) {
|
|
|
1212
1238
|
return serverModels.map((model) => ({
|
|
1213
1239
|
id: model.id,
|
|
1214
1240
|
label: model.label,
|
|
1215
|
-
|
|
1241
|
+
publicId: model.id,
|
|
1242
|
+
costRating: model.costRating,
|
|
1243
|
+
current: model.id === currentModelId,
|
|
1244
|
+
disabled: false,
|
|
1245
|
+
note: model.description,
|
|
1216
1246
|
}));
|
|
1217
1247
|
}
|
|
1218
1248
|
function getDefaultModelPickerIndex(currentModelId, serverModels) {
|
|
@@ -1257,6 +1287,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1257
1287
|
}
|
|
1258
1288
|
await withTuiMode(async () => {
|
|
1259
1289
|
setBackgroundJobSession(session.sessionId);
|
|
1290
|
+
setScratchSession(session.sessionId);
|
|
1260
1291
|
setTodoSession(session.sessionId);
|
|
1261
1292
|
const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
|
|
1262
1293
|
store.replaceTranscript(createSessionTranscript(session));
|
|
@@ -1933,6 +1964,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1933
1964
|
const snapshot = await loadInteractiveSession(selected.id);
|
|
1934
1965
|
applySessionSnapshot(session, snapshot);
|
|
1935
1966
|
setBackgroundJobSession(session.sessionId);
|
|
1967
|
+
setScratchSession(session.sessionId);
|
|
1936
1968
|
syncBackgroundJobsState();
|
|
1937
1969
|
setTodoSession(session.sessionId);
|
|
1938
1970
|
syncTodosState();
|
|
@@ -2531,6 +2563,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2531
2563
|
terminalTitle.dispose();
|
|
2532
2564
|
killAllBackgroundJobs();
|
|
2533
2565
|
setBackgroundJobSession(null);
|
|
2566
|
+
setScratchSession(null);
|
|
2534
2567
|
setTodoSession(null);
|
|
2535
2568
|
setBackgroundJobUpdateHook(null);
|
|
2536
2569
|
await bridge.close();
|
|
@@ -17,9 +17,9 @@ const OVERLAY_PANEL_MAX_WIDTH = 86;
|
|
|
17
17
|
const OVERLAY_PANEL_MARGIN_LINES = 2;
|
|
18
18
|
const OVERLAY_BORDER_COLOR = 'yellow';
|
|
19
19
|
const OVERLAY_WARNING_COLOR = 'ansi256(208)';
|
|
20
|
-
const MODEL_PICKER_PANEL_MAX_WIDTH =
|
|
20
|
+
const MODEL_PICKER_PANEL_MAX_WIDTH = 120;
|
|
21
21
|
const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
|
|
22
|
-
const MODEL_PICKER_BORDER_COLOR = '
|
|
22
|
+
const MODEL_PICKER_BORDER_COLOR = 'cyan';
|
|
23
23
|
const MODEL_PICKER_ACCENT_COLOR = 'cyan';
|
|
24
24
|
const MODEL_PICKER_HIGHLIGHT_BG = 'ansi256(87)';
|
|
25
25
|
const MODEL_PICKER_META_INDENT = ' ';
|
|
@@ -170,8 +170,11 @@ function buildModelPickerOptions(currentModelId, serverModels) {
|
|
|
170
170
|
return serverModels.map((model) => ({
|
|
171
171
|
id: model.id,
|
|
172
172
|
label: model.label,
|
|
173
|
-
|
|
173
|
+
publicId: model.id,
|
|
174
|
+
costRating: model.costRating,
|
|
175
|
+
current: model.id === currentModelId,
|
|
174
176
|
disabled: false,
|
|
177
|
+
note: model.description,
|
|
175
178
|
}));
|
|
176
179
|
}
|
|
177
180
|
function getInputCommandToken(input) {
|
|
@@ -598,77 +601,110 @@ function padSpansToInnerWidth(spans, innerWidth, fill) {
|
|
|
598
601
|
function modelPickerPanelSideLine(content, innerWidth) {
|
|
599
602
|
return overlayPanelLine(content, innerWidth, MODEL_PICKER_BORDER_COLOR);
|
|
600
603
|
}
|
|
604
|
+
const MODEL_PICKER_COST_WIDTH = 6;
|
|
605
|
+
const MODEL_PICKER_MODEL_WIDTH = 42;
|
|
606
|
+
const MODEL_PICKER_NUMBER_WIDTH = 4;
|
|
607
|
+
const MODEL_PICKER_SEPARATOR = ' │ ';
|
|
608
|
+
const MODEL_PICKER_WIDE_MIN_WIDTH = 78;
|
|
601
609
|
function modelPickerTopBorder(panelWidth) {
|
|
602
|
-
const
|
|
603
|
-
const
|
|
604
|
-
const
|
|
605
|
-
|
|
610
|
+
const fullTitle = ' TheGitAI - Model Selection ';
|
|
611
|
+
const compactTitle = ' Model Selection ';
|
|
612
|
+
const title = panelWidth >= fullTitle.length + 4 ? fullTitle : compactTitle;
|
|
613
|
+
const available = Math.max(0, panelWidth - 2 - [...title].length);
|
|
614
|
+
const left = Math.floor(available / 2);
|
|
615
|
+
const right = available - left;
|
|
616
|
+
return line(span(`╭${'─'.repeat(left)}`, { color: MODEL_PICKER_BORDER_COLOR }), span(title, { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(`${'─'.repeat(right)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
|
|
617
|
+
}
|
|
618
|
+
function modelPickerDivider(panelWidth) {
|
|
619
|
+
return plainLine(`├${'─'.repeat(panelWidth - 2)}┤`, {
|
|
620
|
+
color: MODEL_PICKER_BORDER_COLOR,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
function modelPickerCell(text, width, style = {}) {
|
|
624
|
+
const fitted = fitLine(text, width);
|
|
625
|
+
return span(`${fitted}${' '.repeat(Math.max(0, width - [...fitted].length))}`, style);
|
|
626
|
+
}
|
|
627
|
+
function modelPickerCostText(rating) {
|
|
628
|
+
const steps = Math.max(1, Math.min(3, Math.round(rating)));
|
|
629
|
+
return '$'.repeat(steps);
|
|
630
|
+
}
|
|
631
|
+
function modelPickerNotesWidth(innerWidth) {
|
|
632
|
+
return Math.max(18, innerWidth -
|
|
633
|
+
MODEL_PICKER_NUMBER_WIDTH -
|
|
634
|
+
MODEL_PICKER_MODEL_WIDTH -
|
|
635
|
+
MODEL_PICKER_COST_WIDTH -
|
|
636
|
+
MODEL_PICKER_SEPARATOR.length * 2);
|
|
637
|
+
}
|
|
638
|
+
function modelPickerModelSpans(option, selected, width, showCurrentTag, labelStyle, selectedStyle) {
|
|
639
|
+
const tag = option.current && showCurrentTag ? ' (current)' : '';
|
|
640
|
+
const labelWidth = Math.max(1, width - [...tag].length);
|
|
641
|
+
const label = fitLine(`${selected ? '▶ ' : ' '}${option.label}`, labelWidth);
|
|
642
|
+
const used = [...label].length + [...tag].length;
|
|
643
|
+
return [
|
|
644
|
+
span(label, { ...labelStyle, ...selectedStyle }),
|
|
645
|
+
span(tag, { color: 'gray', ...selectedStyle }),
|
|
646
|
+
span(' '.repeat(Math.max(0, width - used)), selectedStyle),
|
|
647
|
+
];
|
|
606
648
|
}
|
|
607
649
|
function modelPickerItemLines(option, selected, innerWidth) {
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
span('
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
], innerWidth, highlight);
|
|
628
|
-
const lines = [modelPickerPanelSideLine(line(...titleSpans), innerWidth)];
|
|
629
|
-
if (option.meta) {
|
|
630
|
-
const metaSpans = padSpansToInnerWidth([
|
|
631
|
-
span(`${MODEL_PICKER_META_INDENT}${option.meta}`, {
|
|
632
|
-
color: 'gray',
|
|
633
|
-
...highlight,
|
|
634
|
-
}),
|
|
635
|
-
], innerWidth, highlight);
|
|
636
|
-
lines.push(modelPickerPanelSideLine(line(...metaSpans), innerWidth));
|
|
637
|
-
}
|
|
638
|
-
return lines;
|
|
650
|
+
const selectedStyle = selected ? { bgColor: MODEL_PICKER_HIGHLIGHT_BG } : {};
|
|
651
|
+
const labelStyle = option.disabled
|
|
652
|
+
? { color: 'gray' }
|
|
653
|
+
: selected
|
|
654
|
+
? { color: 'cyan', bold: true }
|
|
655
|
+
: {};
|
|
656
|
+
const numberCell = modelPickerCell(String(option.publicId), MODEL_PICKER_NUMBER_WIDTH, { color: 'cyan', bold: selected, ...selectedStyle });
|
|
657
|
+
const cost = modelPickerCostText(option.costRating);
|
|
658
|
+
if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
|
|
659
|
+
const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_NUMBER_WIDTH - MODEL_PICKER_COST_WIDTH - 2);
|
|
660
|
+
const row = [
|
|
661
|
+
numberCell,
|
|
662
|
+
...modelPickerModelSpans(option, selected, modelWidth, false, labelStyle, selectedStyle),
|
|
663
|
+
span(' ', selectedStyle),
|
|
664
|
+
modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
|
|
665
|
+
];
|
|
666
|
+
return [
|
|
667
|
+
modelPickerPanelSideLine(line(...padSpansToInnerWidth(row, innerWidth, selectedStyle)), innerWidth),
|
|
668
|
+
];
|
|
639
669
|
}
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
670
|
+
const row = [
|
|
671
|
+
numberCell,
|
|
672
|
+
...modelPickerModelSpans(option, selected, MODEL_PICKER_MODEL_WIDTH, true, labelStyle, selectedStyle),
|
|
673
|
+
span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
|
|
674
|
+
modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
|
|
675
|
+
span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
|
|
676
|
+
modelPickerCell(option.note, modelPickerNotesWidth(innerWidth), {
|
|
677
|
+
color: 'gray',
|
|
678
|
+
...selectedStyle,
|
|
679
|
+
}),
|
|
680
|
+
];
|
|
681
|
+
return [
|
|
682
|
+
modelPickerPanelSideLine(line(...padSpansToInnerWidth(row, innerWidth, selectedStyle)), innerWidth),
|
|
643
683
|
];
|
|
644
|
-
if (option.meta) {
|
|
645
|
-
lines.push(modelPickerPanelSideLine(line(span(`${MODEL_PICKER_META_INDENT}${option.meta}`, { color: 'gray' })), innerWidth));
|
|
646
|
-
}
|
|
647
|
-
return lines;
|
|
648
684
|
}
|
|
649
|
-
function
|
|
650
|
-
|
|
685
|
+
function modelPickerHeaderLine(innerWidth) {
|
|
686
|
+
const heading = { color: MODEL_PICKER_ACCENT_COLOR, bold: true };
|
|
687
|
+
if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
|
|
688
|
+
return line(modelPickerCell('#', MODEL_PICKER_NUMBER_WIDTH, heading), modelPickerCell('Model', Math.max(1, innerWidth - MODEL_PICKER_NUMBER_WIDTH), heading));
|
|
689
|
+
}
|
|
690
|
+
return line(modelPickerCell('#', MODEL_PICKER_NUMBER_WIDTH, heading), modelPickerCell('Model', MODEL_PICKER_MODEL_WIDTH, heading), span(MODEL_PICKER_SEPARATOR, { color: 'gray' }), modelPickerCell('Cost', MODEL_PICKER_COST_WIDTH, heading), span(MODEL_PICKER_SEPARATOR, { color: 'gray' }), modelPickerCell('Notes', modelPickerNotesWidth(innerWidth), heading));
|
|
651
691
|
}
|
|
652
692
|
function buildModelPickerPanel(options, selectedIndex, width) {
|
|
653
693
|
const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
|
|
654
694
|
const innerWidth = Math.max(1, panelWidth - 4);
|
|
655
695
|
const margin = Array.from({ length: MODEL_PICKER_PANEL_MARGIN_LINES }, () => plainLine(''));
|
|
656
696
|
const body = [
|
|
657
|
-
plainLine('TheGitAI - Model Selection', {
|
|
658
|
-
color: MODEL_PICKER_ACCENT_COLOR,
|
|
659
|
-
bold: true,
|
|
660
|
-
}),
|
|
661
|
-
plainLine(''),
|
|
662
697
|
modelPickerTopBorder(panelWidth),
|
|
663
|
-
modelPickerPanelSideLine(
|
|
698
|
+
modelPickerPanelSideLine(modelPickerHeaderLine(innerWidth), innerWidth),
|
|
699
|
+
modelPickerDivider(panelWidth),
|
|
664
700
|
];
|
|
665
701
|
options.forEach((option, index) => {
|
|
666
702
|
body.push(...modelPickerItemLines(option, index === selectedIndex, innerWidth));
|
|
667
|
-
if (index < options.length - 1) {
|
|
668
|
-
body.push(modelPickerSeparatorLine(innerWidth));
|
|
669
|
-
}
|
|
670
703
|
});
|
|
671
|
-
|
|
704
|
+
const fullHint = '↑/↓ navigate • Enter select • Esc cancel';
|
|
705
|
+
const compactHint = '↑/↓ • enter • esc';
|
|
706
|
+
const hint = [...fullHint].length <= innerWidth ? fullHint : compactHint;
|
|
707
|
+
body.push(modelPickerDivider(panelWidth), modelPickerPanelSideLine(plainLine(fitLine(hint, innerWidth), { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
|
|
672
708
|
return [...margin, ...body, ...margin];
|
|
673
709
|
}
|
|
674
710
|
function commandPaletteTopBorder(panelWidth) {
|
|
@@ -677,6 +713,9 @@ function commandPaletteTopBorder(panelWidth) {
|
|
|
677
713
|
const dashCount = Math.max(0, panelWidth - prefix.length - suffix.length);
|
|
678
714
|
return line(span('╭─ ', { color: MODEL_PICKER_BORDER_COLOR }), span('Commands', { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(` ${'─'.repeat(dashCount)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
|
|
679
715
|
}
|
|
716
|
+
function commandPaletteSeparatorLine(innerWidth) {
|
|
717
|
+
return modelPickerPanelSideLine(line(span('┈'.repeat(Math.max(1, innerWidth)), { color: 'gray', dim: true })), innerWidth);
|
|
718
|
+
}
|
|
680
719
|
function commandPaletteItemLines(option, selected, innerWidth) {
|
|
681
720
|
const highlight = { bgColor: MODEL_PICKER_HIGHLIGHT_BG };
|
|
682
721
|
if (selected) {
|
|
@@ -729,7 +768,7 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
|
|
|
729
768
|
suggestions.forEach((suggestion, index) => {
|
|
730
769
|
body.push(...commandPaletteItemLines(suggestion, index === selectedIndex, innerWidth));
|
|
731
770
|
if (index < suggestions.length - 1) {
|
|
732
|
-
body.push(
|
|
771
|
+
body.push(commandPaletteSeparatorLine(innerWidth));
|
|
733
772
|
}
|
|
734
773
|
});
|
|
735
774
|
body.push(modelPickerPanelSideLine(plainLine(''), innerWidth), modelPickerPanelSideLine(line(span('─'.repeat(innerWidth), { color: MODEL_PICKER_BORDER_COLOR })), innerWidth), modelPickerPanelSideLine(plainLine('↑/↓ choose • Tab or Enter accept • Esc cancel', { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
|
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.3",
|
|
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-preview.
|
|
29
|
-
"@thegitai/tui-darwin-x64": "1.0.0-preview.
|
|
30
|
-
"@thegitai/tui-linux-x64": "1.0.0-preview.
|
|
31
|
-
"@thegitai/tui-win32-x64": "1.0.0-preview.
|
|
28
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-preview.3",
|
|
29
|
+
"@thegitai/tui-darwin-x64": "1.0.0-preview.3",
|
|
30
|
+
"@thegitai/tui-linux-x64": "1.0.0-preview.3",
|
|
31
|
+
"@thegitai/tui-win32-x64": "1.0.0-preview.3",
|
|
32
32
|
"@vscode/ripgrep": "1.18.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|