@thegitai/cli 1.0.0-preview.4 → 1.0.0-preview.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +49 -4
  2. package/dist/bin/ai.js +119 -111
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +11 -6
  5. package/dist/src/api/auth.js +6 -4
  6. package/dist/src/api/browser-login.js +152 -37
  7. package/dist/src/api/chat.js +286 -44
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +127 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/background-jobs.js +4 -2
  13. package/dist/src/browser/bridge.js +232 -0
  14. package/dist/src/browser/framing.js +42 -0
  15. package/dist/src/browser/native-host.js +209 -0
  16. package/dist/src/browser/protocol.js +46 -0
  17. package/dist/src/browser/session-bridge.js +104 -0
  18. package/dist/src/cli-args.js +19 -5
  19. package/dist/src/client-environment.js +2 -0
  20. package/dist/src/core/clipboard.js +7 -13
  21. package/dist/src/core/image-limits.js +56 -0
  22. package/dist/src/core/image-path-extractor.js +70 -3
  23. package/dist/src/core/session-image-store.js +246 -0
  24. package/dist/src/edit-journal.js +33 -2
  25. package/dist/src/executor.js +25 -3
  26. package/dist/src/help-text.js +58 -17
  27. package/dist/src/patcher.js +43 -4
  28. package/dist/src/permissions.js +288 -0
  29. package/dist/src/session-safety.js +0 -12
  30. package/dist/src/session-store.js +121 -20
  31. package/dist/src/session.js +19 -4
  32. package/dist/src/signin.js +58 -0
  33. package/dist/src/tool-executor.js +12 -46
  34. package/dist/src/tools/browser.js +628 -0
  35. package/dist/src/tools/delete-file.js +20 -4
  36. package/dist/src/tools/index.js +32 -10
  37. package/dist/src/tools/patch-file.js +12 -26
  38. package/dist/src/tools/read-image-file.js +85 -0
  39. package/dist/src/tools/replace-document-text.js +28 -18
  40. package/dist/src/tools/restore-checkpoint.js +0 -1
  41. package/dist/src/tools/run-command.js +14 -71
  42. package/dist/src/tools/run-node-script.js +12 -81
  43. package/dist/src/tools/save-generated-image.js +120 -0
  44. package/dist/src/tools/str-replace.js +12 -26
  45. package/dist/src/tools/undo-edit.js +1 -6
  46. package/dist/src/tools/write-file.js +67 -11
  47. package/dist/src/ui/prompt-history-store.js +1 -1
  48. package/dist/src/ui/repl.js +802 -195
  49. package/dist/src/ui/tui/bridge.js +10 -0
  50. package/dist/src/ui/tui/build-frame.js +517 -165
  51. package/dist/src/ui/tui/composer-layout.js +76 -0
  52. package/dist/src/ui/tui/markdown-render.js +81 -73
  53. package/dist/src/ui/tui/shell-input.js +227 -67
  54. package/dist/src/ui/tui/terminal-theme.js +28 -0
  55. package/dist/src/ui/tui/terminal-title.js +3 -0
  56. package/dist/src/ui/tui/terminal-writes.js +48 -0
  57. package/dist/src/ui/tui/text.js +158 -4
  58. package/dist/src/ui/tui/user-input.js +568 -0
  59. package/dist/src/utils.js +9 -0
  60. package/package.json +20 -8
  61. package/dist/src/markdown-renderer.js +0 -112
  62. package/dist/src/project-index.js +0 -221
  63. package/dist/src/tools/code-intel.js +0 -472
  64. package/dist/src/tools/find-symbol.js +0 -70
  65. package/dist/src/tools/hover-symbol.js +0 -95
  66. package/dist/src/tools/list-symbols.js +0 -55
  67. package/dist/src/tools/search-code.js +0 -37
  68. package/dist/src/tools/signature-help.js +0 -118
@@ -0,0 +1,246 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { getClientStateDir } from '../client-state.js';
5
+ import { MAX_IMAGE_SIZE_BYTES, imageExtensionForMime, isSupportedImageMimeType, sniffImageMimeType, } from './image-limits.js';
6
+ const IMAGE_FILE_MODE = 0o600;
7
+ const IMAGE_DIR_MODE = 0o700;
8
+ export const SESSION_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
9
+ let activeSessionId = null;
10
+ export function setImageStoreSession(sessionId) {
11
+ activeSessionId = String(sessionId ?? '').trim() || null;
12
+ contentIndex.clear();
13
+ }
14
+ const contentIndex = new Map();
15
+ function imageContentKey(base64Data) {
16
+ return createHash('sha256').update(base64Data).digest('hex');
17
+ }
18
+ function rememberStoredImage(base64Data, cachePath) {
19
+ contentIndex.set(imageContentKey(base64Data), cachePath);
20
+ }
21
+ export function findStoredImageByContent(base64Data) {
22
+ const cachePath = contentIndex.get(imageContentKey(base64Data));
23
+ if (!cachePath)
24
+ return undefined;
25
+ if (!existsSync(cachePath)) {
26
+ contentIndex.delete(imageContentKey(base64Data));
27
+ return undefined;
28
+ }
29
+ return cachePath;
30
+ }
31
+ export function getImageStoreSession() {
32
+ return activeSessionId;
33
+ }
34
+ function safeSessionDirName(sessionId) {
35
+ const normalized = String(sessionId ?? '').trim();
36
+ if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
37
+ throw new Error(`Invalid session id "${sessionId}".`);
38
+ }
39
+ return normalized;
40
+ }
41
+ export function getSessionImageBaseDir(env = process.env) {
42
+ return path.join(getClientStateDir(env), 'sessions', 'images');
43
+ }
44
+ export function getSessionImageDir(sessionId, env = process.env) {
45
+ return path.join(getSessionImageBaseDir(env), safeSessionDirName(sessionId));
46
+ }
47
+ function nextImageNumber(dir) {
48
+ if (!existsSync(dir))
49
+ return 1;
50
+ let highest = 0;
51
+ for (const name of readdirSync(dir)) {
52
+ const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
53
+ if (Number.isInteger(parsed) && parsed > highest)
54
+ highest = parsed;
55
+ }
56
+ return highest + 1;
57
+ }
58
+ function ensureSessionImageDir(sessionId, env) {
59
+ const dir = getSessionImageDir(sessionId, env);
60
+ mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
61
+ return dir;
62
+ }
63
+ export function storeSessionImageBytes({ sessionId, base64Data, mimeType, env = process.env, }) {
64
+ const dir = ensureSessionImageDir(sessionId, env);
65
+ const index = nextImageNumber(dir);
66
+ const cachePath = path.join(dir, `${index}${imageExtensionForMime(mimeType)}`);
67
+ writeFileSync(cachePath, Buffer.from(base64Data, 'base64'), {
68
+ mode: IMAGE_FILE_MODE,
69
+ });
70
+ rememberStoredImage(base64Data, cachePath);
71
+ return { cachePath, mimeType, base64Data, index };
72
+ }
73
+ export function tryCacheAttachmentBytes({ base64Data, mimeType, env = process.env, }) {
74
+ if (!activeSessionId)
75
+ return undefined;
76
+ try {
77
+ const stored = storeSessionImageBytes({
78
+ sessionId: activeSessionId,
79
+ base64Data,
80
+ mimeType,
81
+ env,
82
+ });
83
+ return { cachePath: stored.cachePath, index: stored.index };
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ export function isSessionStorePath(candidate, env = process.env) {
90
+ if (!activeSessionId)
91
+ return false;
92
+ const dir = getSessionImageDir(activeSessionId, env);
93
+ const resolved = path.resolve(candidate);
94
+ return (resolved.startsWith(dir + path.sep) && path.dirname(resolved) === dir);
95
+ }
96
+ export function readSessionImageByIndex(index, env = process.env) {
97
+ if (!activeSessionId || !Number.isInteger(index) || index < 1)
98
+ return null;
99
+ const dir = getSessionImageDir(activeSessionId, env);
100
+ if (!existsSync(dir))
101
+ return null;
102
+ try {
103
+ for (const name of readdirSync(dir)) {
104
+ if (Number.parseInt(path.basename(name, path.extname(name)), 10) === index) {
105
+ return readSessionImage(path.join(dir, name));
106
+ }
107
+ }
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ return null;
113
+ }
114
+ export function describeSessionImageStore(env = process.env) {
115
+ if (!activeSessionId)
116
+ return null;
117
+ const dir = getSessionImageDir(activeSessionId, env);
118
+ if (!existsSync(dir))
119
+ return null;
120
+ const indices = [];
121
+ try {
122
+ for (const name of readdirSync(dir)) {
123
+ const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
124
+ if (Number.isInteger(parsed) && parsed >= 1)
125
+ indices.push(parsed);
126
+ }
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ if (indices.length === 0)
132
+ return null;
133
+ return { dir, indices: indices.sort((a, b) => a - b) };
134
+ }
135
+ const MAX_STORE_DIR_CHARS = 512;
136
+ const MAX_STORE_INDICES = 200;
137
+ const STORE_CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
138
+ export function normalizeSessionImageStore(value) {
139
+ if (!value || typeof value !== 'object')
140
+ return null;
141
+ const record = value;
142
+ const dir = String(record.dir ?? '')
143
+ .replace(STORE_CONTROL_CHARS, ' ')
144
+ .trim()
145
+ .slice(0, MAX_STORE_DIR_CHARS);
146
+ if (!dir)
147
+ return null;
148
+ const seen = new Set();
149
+ for (const raw of Array.isArray(record.indices) ? record.indices : []) {
150
+ const parsed = Number(raw);
151
+ if (!Number.isInteger(parsed) || parsed < 1)
152
+ continue;
153
+ seen.add(parsed);
154
+ if (seen.size >= MAX_STORE_INDICES)
155
+ break;
156
+ }
157
+ if (seen.size === 0)
158
+ return null;
159
+ return { dir, indices: [...seen].sort((a, b) => a - b) };
160
+ }
161
+ export class SessionImageError extends Error {
162
+ code;
163
+ constructor(message, code) {
164
+ super(message);
165
+ this.code = code;
166
+ this.name = 'SessionImageError';
167
+ }
168
+ }
169
+ export function storeSessionImageFromPath({ sessionId, sourcePath, env = process.env, }) {
170
+ const resolved = path.resolve(sourcePath);
171
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) {
172
+ throw new SessionImageError(`Image file not found: ${resolved}`, 'NOT_FOUND');
173
+ }
174
+ const size = statSync(resolved).size;
175
+ if (size > MAX_IMAGE_SIZE_BYTES) {
176
+ throw new SessionImageError(`Image file exceeds ${Math.round(MAX_IMAGE_SIZE_BYTES / 1024 / 1024)}MB limit (${(size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'TOO_LARGE');
177
+ }
178
+ const bytes = readFileSync(resolved);
179
+ const sniffed = sniffImageMimeType(bytes);
180
+ if (!sniffed || !isSupportedImageMimeType(sniffed)) {
181
+ throw new SessionImageError(`Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'UNSUPPORTED');
182
+ }
183
+ const dir = ensureSessionImageDir(sessionId, env);
184
+ const index = nextImageNumber(dir);
185
+ const cachePath = path.join(dir, `${index}${imageExtensionForMime(sniffed)}`);
186
+ writeFileSync(cachePath, bytes, { mode: IMAGE_FILE_MODE });
187
+ const base64Data = bytes.toString('base64');
188
+ rememberStoredImage(base64Data, cachePath);
189
+ return { cachePath, mimeType: sniffed, base64Data, index };
190
+ }
191
+ export function readSessionImage(cachePath) {
192
+ try {
193
+ if (!existsSync(cachePath))
194
+ return null;
195
+ const bytes = readFileSync(cachePath);
196
+ const sniffed = sniffImageMimeType(bytes);
197
+ if (!sniffed)
198
+ return null;
199
+ const parsed = Number.parseInt(path.basename(cachePath, path.extname(cachePath)), 10);
200
+ const base64Data = bytes.toString('base64');
201
+ rememberStoredImage(base64Data, cachePath);
202
+ return {
203
+ cachePath,
204
+ mimeType: sniffed,
205
+ base64Data,
206
+ index: Number.isInteger(parsed) ? parsed : 0,
207
+ };
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ export function pruneSessionImages(sessionId, env = process.env) {
214
+ try {
215
+ rmSync(getSessionImageDir(sessionId, env), { recursive: true, force: true });
216
+ }
217
+ catch {
218
+ }
219
+ }
220
+ export function sweepOrphanSessionImages({ activeSessionIds, maxAgeMs = SESSION_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), }) {
221
+ const baseDir = getSessionImageBaseDir(env);
222
+ if (!existsSync(baseDir))
223
+ return 0;
224
+ let removed = 0;
225
+ let entries;
226
+ try {
227
+ entries = readdirSync(baseDir);
228
+ }
229
+ catch {
230
+ return 0;
231
+ }
232
+ for (const entry of entries) {
233
+ if (activeSessionIds.has(entry))
234
+ continue;
235
+ const dir = path.join(baseDir, entry);
236
+ try {
237
+ if (now - statSync(dir).mtimeMs < maxAgeMs)
238
+ continue;
239
+ rmSync(dir, { recursive: true, force: true });
240
+ removed += 1;
241
+ }
242
+ catch {
243
+ }
244
+ }
245
+ return removed;
246
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
+ import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { BINARY_ARTIFACT_EXTENSIONS } from './artifact-policy.js';
6
6
  import { resolveProjectPath } from './patcher.js';
@@ -30,13 +30,33 @@ function snapshotEncoding(filePath, content) {
30
30
  return 'base64';
31
31
  return 'utf8';
32
32
  }
33
+ const MAX_SNAPSHOT_READ_BYTES = MAX_STORED_CONTENT_CHARS;
34
+ const SNAPSHOT_HASH_CHUNK_BYTES = 1024 * 1024;
35
+ function hashFileInChunks(absPath) {
36
+ const hash = createHash('sha256');
37
+ const chunk = Buffer.allocUnsafe(SNAPSHOT_HASH_CHUNK_BYTES);
38
+ const fd = openSync(absPath, 'r');
39
+ try {
40
+ for (;;) {
41
+ const read = readSync(fd, chunk, 0, chunk.length, null);
42
+ if (read <= 0)
43
+ break;
44
+ hash.update(chunk.subarray(0, read));
45
+ }
46
+ }
47
+ finally {
48
+ closeSync(fd);
49
+ }
50
+ return `sha256:${hash.digest('hex')}`;
51
+ }
33
52
  export function readFileEditSnapshot(rootDir, filePath) {
34
53
  try {
35
54
  const absPath = resolveProjectPath(rootDir, filePath);
36
55
  if (!existsSync(absPath)) {
37
56
  return { exists: false, content: null, contentEncoding: 'utf8', hash: null };
38
57
  }
39
- if (lstatSync(absPath).isSymbolicLink()) {
58
+ const stat = lstatSync(absPath);
59
+ if (stat.isSymbolicLink()) {
40
60
  return {
41
61
  exists: true,
42
62
  content: null,
@@ -45,6 +65,17 @@ export function readFileEditSnapshot(rootDir, filePath) {
45
65
  error: `Refusing to snapshot symbolic link: ${filePath}`,
46
66
  };
47
67
  }
68
+ if (stat.isFile() && stat.size > MAX_SNAPSHOT_READ_BYTES) {
69
+ return {
70
+ exists: true,
71
+ content: null,
72
+ contentEncoding: BINARY_ARTIFACT_EXTENSIONS.has(path.extname(filePath).toLowerCase())
73
+ ? 'base64'
74
+ : 'utf8',
75
+ hash: hashFileInChunks(absPath),
76
+ error: `File is too large to snapshot (${stat.size} bytes).`,
77
+ };
78
+ }
48
79
  const content = readFileSync(absPath);
49
80
  const encoding = snapshotEncoding(filePath, content);
50
81
  return {
@@ -1,6 +1,6 @@
1
1
  import chalk from './colors.js';
2
2
  import { execFileSync, spawn } from 'child_process';
3
- import { existsSync, statSync } from 'fs';
3
+ import { accessSync, constants as fsConstants, existsSync, statSync } from 'fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import os from 'os';
6
6
  import path from 'path';
@@ -596,7 +596,7 @@ export function commandUsesSudo(command) {
596
596
  }
597
597
  export function sudoPromptFromTail(text) {
598
598
  const tail = text.slice(-1000).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
599
- const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*: ?|sudo[^\r\n]*password[^\r\n]*: ?|password[^\r\n]*: ?)$/i);
599
+ const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*: ?|\[?sudo[^\r\n]*password[^\r\n]*: ?|password[^\r\n]*: ?)$/i);
600
600
  return match?.[0] ?? null;
601
601
  }
602
602
  function isSudoPromptLine(text) {
@@ -637,6 +637,28 @@ export function buildCommandEnv(cwd) {
637
637
  THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
638
638
  };
639
639
  }
640
+ let cachedCommandShell;
641
+ function findBashPath() {
642
+ const fromPath = (process.env.PATH ?? '')
643
+ .split(path.delimiter)
644
+ .filter(Boolean)
645
+ .map((dir) => path.join(dir, 'bash'));
646
+ for (const candidate of [...fromPath, '/bin/bash', '/usr/bin/bash']) {
647
+ try {
648
+ accessSync(candidate, fsConstants.X_OK);
649
+ return candidate;
650
+ }
651
+ catch { }
652
+ }
653
+ return null;
654
+ }
655
+ export function resolveCommandShell() {
656
+ if (cachedCommandShell !== undefined)
657
+ return cachedCommandShell;
658
+ cachedCommandShell =
659
+ process.platform === 'win32' ? true : (findBashPath() ?? true);
660
+ return cachedCommandShell;
661
+ }
640
662
  function sanitizePtyOutput(command, output, cwd, secrets) {
641
663
  return sanitizeCommandText(command, stripSudoPromptText(redactSecrets(output, secrets)), cwd);
642
664
  }
@@ -822,7 +844,7 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
822
844
  let killTimer = null;
823
845
  const child = spawn(command, {
824
846
  cwd,
825
- shell: true,
847
+ shell: resolveCommandShell(),
826
848
  detached: process.platform !== 'win32',
827
849
  stdio: ['pipe', 'pipe', 'pipe'],
828
850
  env: buildCommandEnv(cwd),
@@ -14,7 +14,7 @@ const PASTE_SHORTCUT = pasteShortcutForPlatform();
14
14
  const HELP_MARKDOWN = [
15
15
  '# TheGitAI',
16
16
  '',
17
- 'Interactive terminal coding agent. Local repo indexing, file edits, and',
17
+ 'Interactive terminal coding agent. Local repo search, file edits, and',
18
18
  'shell commands run on your machine. Model inference and server-executed',
19
19
  'tools run on the server.',
20
20
  '',
@@ -26,8 +26,12 @@ const HELP_MARKDOWN = [
26
26
  '',
27
27
  '## Auth',
28
28
  '',
29
- '- `ai login` — sign in to TheGitAI (opens your browser to the website)',
30
- '- `ai login --no-browser` — print a URL and paste the code (for SSH/headless)',
29
+ '- `ai` — signs you in if you are not already, then starts a session',
30
+ '- `ai login` — the same thing; kept for muscle memory',
31
+ '- Signing in opens your browser. When it cannot open one — over SSH, in a',
32
+ ' container, or when the browser you want is on another device — the same',
33
+ ' screen prints a URL you can open anywhere and takes the code that page',
34
+ ' gives you back. There is no mode to pick and no flag to remember.',
31
35
  '- `ai whoami` — show the signed-in account',
32
36
  '- `ai --usage` — show account usage percentage and reset times',
33
37
  '- `ai logout` — sign out',
@@ -47,7 +51,9 @@ const HELP_MARKDOWN = [
47
51
  '',
48
52
  '## Modes',
49
53
  '',
50
- '- Default — asks before shell commands and file edits.',
54
+ '- Default — asks before creating, editing or deleting files and before',
55
+ ' running commands. Each answer can be remembered for the rest of the',
56
+ ' session; see Safety & approvals.',
51
57
  '- Auto-Accept — approves shell commands and file edits for the session.',
52
58
  '- Plan — read-only; the agent can inspect, ask typed questions, and plan,',
53
59
  ' with file-reading shell commands only. It will not edit files or run tests,',
@@ -57,7 +63,15 @@ const HELP_MARKDOWN = [
57
63
  '## Keys & clipboard',
58
64
  '',
59
65
  '- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
60
- ' **Ctrl+C** quits. These are the same on macOS, Linux, and Windows.',
66
+ ' **Ctrl+C** clears the composer or the queued message, and quits once there',
67
+ ' is nothing left to clear. These are the same on macOS, Linux, and Windows.',
68
+ '- **While the agent is working**, Enter queues your message and locks the',
69
+ ' composer. Press **Enter** again to send it into the running turn: the agent',
70
+ ' picks it up at its next step instead of waiting for the turn to finish, and',
71
+ ' the Your messages panel tracks it from queued to delivered. **↑** brings it',
72
+ ' back for editing and **Esc**/**Ctrl+C** discards it; both unlock the',
73
+ ' composer for the next message. **Ctrl+V** pastes a screenshot onto the',
74
+ ' queued message, and it is delivered into the running turn with the text.',
61
75
  `- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
62
76
  ' on this system) or by right-clicking the composer.',
63
77
  '- **Copy** from the transcript by dragging to select; double-click copies a',
@@ -70,22 +84,46 @@ const HELP_MARKDOWN = [
70
84
  '',
71
85
  '- `/help` — show this help',
72
86
  '- `/about` — show version and platform info',
87
+ '- `/report` — file a bug privately through the TheGitAI website in your',
88
+ ' browser (sign in with your existing account)',
73
89
  '- `/usage` — show account usage percentage and reset times',
74
90
  '- `/model` — list supported models and pick one',
75
91
  '- `/model <id>` — switch the active model without clearing history',
76
92
  '- `/resume` — resume a saved session for this repo',
77
93
  '- `/jobs` — manage long-running commands like dev servers and watchers:',
78
94
  ' browse them, press Enter to expand one and read its output, k to stop it',
95
+ '- Ctrl+B — open the same jobs picker, and the only way in while the agent',
96
+ ' is working; press it again or Esc to close without touching the turn',
79
97
  '- `/jobs output <id>` — print one job\'s full captured output',
80
98
  '- `/jobs kill <id>` — stop one background job',
81
- '- `/clear` — clear the current conversation history',
99
+ '- `/new` — start a new conversation; this session remains saved',
100
+ '- `/logout` — sign out and quit',
82
101
  '- `/exit` — quit the session',
83
102
  '',
84
103
  '## Safety & approvals',
85
104
  '',
86
- '- TheGitAI asks before running shell commands or applying file edits.',
87
- '- At each prompt: **y** approves once, **a** approves the rest of the',
88
- ' session, **n** denies.',
105
+ '- In Default mode TheGitAI asks before it creates a file, edits an existing',
106
+ ' file, deletes a file, or runs a command. These are four separate',
107
+ ' permissions: allowing edits does not allow deletions, and allowing file',
108
+ ' changes does not allow commands.',
109
+ '- At each prompt: **↑/↓** moves between choices, **Enter** confirms the',
110
+ ' highlighted one, and **Esc** denies. Deny is selected by default.',
111
+ ' Single-letter shortcuts were removed on purpose: a prompt can appear',
112
+ ' while you are typing, and a stray letter must never approve anything.',
113
+ '- Every prompt offers Approve once, an "Always allow" for that kind of',
114
+ ' action, and Deny. A command also offers to remember just its prefix — for',
115
+ ' example approving `npm test -- --watch=false` can allow `npm test`.',
116
+ '- A prefix is only offered when the command shows a plain verb, as in',
117
+ ' `npm test` or `git status`. Commands with no verb (`ls -la`), interpreters',
118
+ ' and wrappers (`bash -c`, `python -c`, `env`, `timeout`, `make`), and',
119
+ ' destructive or network binaries (`rm`, `sudo`, `curl`) are never offered',
120
+ ' one — a bare binary grant would mean "anything this program can do".',
121
+ ' `npm run` must name its script, so the grant is `npm run build`.',
122
+ '- A remembered prefix never covers a command that chains, pipes, redirects,',
123
+ ' or substitutes, so allowing `npm test` can never green-light',
124
+ ' `npm test && rm -rf build`.',
125
+ '- Everything you allow lasts for the current session only and is forgotten',
126
+ ' when it ends. `/new` clears it immediately.',
89
127
  '- If an approved `sudo` command needs a password, the TUI shows the exact',
90
128
  ' command and keeps the password masked and local.',
91
129
  '- `-y` / `--yes` at startup auto-approves every shell command and file',
@@ -94,24 +132,27 @@ const HELP_MARKDOWN = [
94
132
  ' jobs after the same approval as any other command. Background output',
95
133
  ' stays quiet once the model has responded; the footer shows only a compact',
96
134
  ' shell-running indicator, `/jobs` lists, inspects, and kills jobs, and',
97
- ' killed jobs disappear immediately. Every job is killed when the session',
98
- ' ends.',
135
+ ' killed jobs disappear immediately. While a turn is running the indicator',
136
+ ' points at Ctrl+B instead, which opens the same picker without cancelling',
137
+ ' or interrupting the turn. Every job is killed when the session ends.',
99
138
  '- File and shell operations are confined to the target repo root.',
100
139
  '- Sensitive directories (`.git`, `node_modules`, build output) are',
101
- ' never indexed.',
140
+ ' excluded from search and listing.',
102
141
  '',
103
142
  '## Troubleshooting',
104
143
  '',
105
- '- "Not logged in" → run `ai login`.',
106
144
  '- Auth or permission errors → run `ai whoami` to confirm the signed-in',
107
145
  ' account.',
108
146
  '- Usage or quota errors → run `ai --usage`.',
109
- '- Signed in with the wrong credentials → `ai logout`, then `ai login` with',
110
- ' the account you intended to use.',
147
+ '- Signed in with the wrong account → `/logout` (or `ai logout`), then run',
148
+ ' `ai` and sign in as the account you intended to use.',
149
+ '- The browser did not open, or opened on the wrong machine → open the URL',
150
+ ' printed on the sign-in screen anywhere you like, choose "On another',
151
+ ' computer" on that page, and paste the code back into the terminal.',
111
152
  '- A local session was used with a different sign-in → sign in with the',
112
153
  ' account you used for that session or start a new session.',
113
- '- For anything else, re-run the command and report the printed error',
114
- ' message — there is no client-side debug mode by design.',
154
+ '- For anything else, re-run the command and file the printed error',
155
+ ' message with `/report` — there is no client-side debug mode by design.',
115
156
  ].join('\n');
116
157
  export function formatAboutCard() {
117
158
  return [
@@ -1,5 +1,5 @@
1
1
  import chalk from './colors.js';
2
- import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from 'fs';
2
+ import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, unlinkSync, writeFileSync, } from 'fs';
3
3
  import path from 'path';
4
4
  import { createInterface } from 'readline';
5
5
  import { runCommand } from './executor.js';
@@ -271,22 +271,61 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
271
271
  }
272
272
  return { absPath, changed: true };
273
273
  }
274
+ const MAX_DELETED_CONTENT_BYTES = 64 * 1024;
275
+ const DELETED_BINARY_SNIFF_BYTES = 4096;
276
+ function headHasNulByte(absPath) {
277
+ const chunk = Buffer.allocUnsafe(DELETED_BINARY_SNIFF_BYTES);
278
+ let fd = null;
279
+ try {
280
+ fd = openSync(absPath, 'r');
281
+ const read = readSync(fd, chunk, 0, chunk.length, 0);
282
+ return read > 0 && chunk.subarray(0, read).includes(0);
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ finally {
288
+ if (fd !== null)
289
+ closeSync(fd);
290
+ }
291
+ }
274
292
  export function deleteProjectFile(rootDir, filePath) {
275
293
  const absPath = resolveProjectPath(rootDir, filePath);
276
294
  if (!existsSync(absPath)) {
277
295
  return { deleted: false, absPath };
278
296
  }
297
+ let bytes;
279
298
  let content;
299
+ let contentOmitted;
280
300
  try {
281
- if (!lstatSync(absPath).isSymbolicLink()) {
282
- content = readFileSync(absPath, 'utf-8');
301
+ const stat = lstatSync(absPath);
302
+ if (!stat.isSymbolicLink()) {
303
+ bytes = stat.size;
304
+ if (stat.size > MAX_DELETED_CONTENT_BYTES) {
305
+ contentOmitted = headHasNulByte(absPath) ? 'binary' : 'too_large';
306
+ }
307
+ else {
308
+ const buffer = readFileSync(absPath);
309
+ if (buffer.includes(0))
310
+ contentOmitted = 'binary';
311
+ else
312
+ content = buffer.toString('utf-8');
313
+ }
283
314
  }
284
315
  }
285
316
  catch {
317
+ bytes = undefined;
286
318
  content = undefined;
319
+ contentOmitted = undefined;
287
320
  }
288
321
  unlinkSync(absPath);
289
- return { deleted: true, absPath, content };
322
+ return {
323
+ deleted: true,
324
+ absPath,
325
+ ...(bytes === undefined ? {} : { bytes }),
326
+ ...(content === undefined ? {} : { content }),
327
+ ...(contentOmitted === undefined ? {} : { contentOmitted }),
328
+ };
290
329
  }
291
330
  export function readProjectFile(rootDir, filePath) {
292
331
  const absPath = resolveProjectPath(rootDir, filePath);