@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 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) => {
@@ -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,
@@ -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
- if (!Number.isInteger(id) || id <= 0 || !label) {
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');
@@ -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
- export function resolveProjectPath(rootDir, filePath) {
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('..') || path.isAbsolute(relative)) {
151
- throw new Error(`Refusing to access path outside the project root: ${filePath}`);
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
- mkdirSync(path.dirname(absPath), { recursive: true });
168
- writeFileSync(absPath, content, 'utf-8');
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
- mkdirSync(path.dirname(absPath), { recursive: true });
184
- writeFileSync(absPath, content);
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
+ }
@@ -1,57 +1,75 @@
1
- import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
1
+ import { chmodSync, lstatSync, mkdtempSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- let cachedScratchDir = null;
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
- if (!cachedScratchDir) {
7
- cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
8
- }
9
- return cachedScratchDir;
15
+ return scratchDirs.get(activeSessionId) ?? allocateSessionScratchDir();
10
16
  }
11
- function isSquattedScratchRoot(dir) {
17
+ function isOwnedDirectory(dir) {
12
18
  try {
13
19
  const st = lstatSync(dir);
14
20
  if (st.isSymbolicLink() || !st.isDirectory())
15
- return true;
21
+ return false;
16
22
  if (process.platform !== 'win32' &&
17
23
  typeof process.getuid === 'function' &&
18
24
  st.uid !== process.getuid()) {
19
- return true;
25
+ return false;
20
26
  }
21
- return false;
27
+ return true;
22
28
  }
23
29
  catch {
24
30
  return false;
25
31
  }
26
32
  }
27
33
  export function ensureSessionScratchDir() {
28
- const dir = sessionScratchDir();
29
- try {
30
- if (isSquattedScratchRoot(dir)) {
31
- cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
32
- return cachedScratchDir;
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
- mkdirSync(dir, { recursive: true, mode: 0o700 });
35
- if (process.platform !== 'win32') {
36
- chmodSync(dir, 0o700);
56
+ catch (error) {
57
+ return error?.code !== 'ENOENT';
37
58
  }
38
59
  }
39
- catch {
40
- }
41
- return sessionScratchDir();
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 tempRoot = path.resolve(os.tmpdir());
45
- const relPath = path.relative(tempRoot, path.resolve(absPath));
46
- if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
47
- return false;
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
- (priorSync.added || priorSync.modified || priorSync.removed))) {
89
- result.repoSync = await syncIndexFromDisk(projectIndex);
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
- if (filePathBeforeEdit) {
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 = filePathBeforeEdit
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
- await removeIndexFile(projectIndex, filePath);
15
- invalidateShellDiagnosticsCache(rootDir, filePath);
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
- error: 'User declined patch',
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
- invalidateShellDiagnosticsCache(rootDir, filePath);
84
- const diagnostics = runShellDiagnostics(rootDir, filePath);
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
- error: 'User declined replace_document_text',
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
- error: 'User declined command execution. Do not rerun this command or try a broader variant of it; either continue without it or ask the user one specific question about how to proceed.',
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.initialized
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.initialized
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
- error: 'User declined command execution',
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
- const repoSync = shouldSync
190
- ? await syncIndexFromDisk(context.projectIndex)
191
- : emptyRepoSync(!context.projectIndex.initialized
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
- error: 'User declined str_replace',
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
- invalidateShellDiagnosticsCache(rootDir, filePath);
187
- const diagnostics = runShellDiagnostics(rootDir, filePath);
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 (currentHash !== null &&
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
- invalidateShellDiagnosticsCache(rootDir, filePath);
56
- const diagnostics = runShellDiagnostics(rootDir, filePath);
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'),
@@ -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
- meta: model.id === currentModelId ? 'current' : '',
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 = 86;
20
+ const MODEL_PICKER_PANEL_MAX_WIDTH = 120;
21
21
  const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
22
- const MODEL_PICKER_BORDER_COLOR = 'gray';
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
- meta: model.id === currentModelId ? 'current' : '',
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 prefix = '╭─ Models ';
603
- const suffix = '';
604
- const dashCount = Math.max(0, panelWidth - prefix.length - suffix.length);
605
- return line(span('╭─ ', { color: MODEL_PICKER_BORDER_COLOR }), span('Models', { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(` ${'─'.repeat(dashCount)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
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 highlight = { bgColor: MODEL_PICKER_HIGHLIGHT_BG };
609
- if (selected) {
610
- const titleSpans = padSpansToInnerWidth([
611
- span('▌', {
612
- color: MODEL_PICKER_ACCENT_COLOR,
613
- bold: true,
614
- ...highlight,
615
- }),
616
- span('▶ ', {
617
- color: MODEL_PICKER_ACCENT_COLOR,
618
- bold: true,
619
- ...highlight,
620
- }),
621
- span('o ', { color: MODEL_PICKER_ACCENT_COLOR, ...highlight }),
622
- span(option.label, {
623
- color: MODEL_PICKER_ACCENT_COLOR,
624
- bold: true,
625
- ...highlight,
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 labelColor = option.disabled ? 'gray' : MODEL_PICKER_ACCENT_COLOR;
641
- const lines = [
642
- modelPickerPanelSideLine(line(span(' o ', { color: labelColor }), span(option.label, { color: labelColor, bold: !option.disabled })), innerWidth),
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 modelPickerSeparatorLine(innerWidth) {
650
- return modelPickerPanelSideLine(line(span('┈'.repeat(Math.max(1, innerWidth)), { color: 'gray', dim: true })), innerWidth);
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(plainLine(''), innerWidth),
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
- body.push(modelPickerPanelSideLine(plainLine(''), innerWidth), modelPickerPanelSideLine(line(span('─'.repeat(innerWidth), { color: MODEL_PICKER_BORDER_COLOR })), innerWidth), modelPickerPanelSideLine(plainLine('↑/↓ choose • Enter select • Esc cancel', { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
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(modelPickerSeparatorLine(innerWidth));
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.1",
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.1",
29
- "@thegitai/tui-darwin-x64": "1.0.0-preview.1",
30
- "@thegitai/tui-linux-x64": "1.0.0-preview.1",
31
- "@thegitai/tui-win32-x64": "1.0.0-preview.1",
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": {