@thegitai/cli 1.0.0-preview.16 → 1.0.0-preview.18

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.
@@ -46,7 +46,7 @@ export function nextAgentMode(mode) {
46
46
  export function agentModeAllowsTool(mode, toolName) {
47
47
  return mode !== 'plan' || PLAN_MODE_TOOL_NAMES.has(toolName);
48
48
  }
49
- function getUnquotedShellText(command) {
49
+ export function getUnquotedShellText(command) {
50
50
  let quote = null;
51
51
  let escaped = false;
52
52
  let text = '';
@@ -47,7 +47,9 @@ const HELP_MARKDOWN = [
47
47
  '',
48
48
  '## Modes',
49
49
  '',
50
- '- Default — asks before shell commands and file edits.',
50
+ '- Default — asks before creating, editing or deleting files and before',
51
+ ' running commands. Each answer can be remembered for the rest of the',
52
+ ' session; see Safety & approvals.',
51
53
  '- Auto-Accept — approves shell commands and file edits for the session.',
52
54
  '- Plan — read-only; the agent can inspect, ask typed questions, and plan,',
53
55
  ' with file-reading shell commands only. It will not edit files or run tests,',
@@ -91,12 +93,28 @@ const HELP_MARKDOWN = [
91
93
  '',
92
94
  '## Safety & approvals',
93
95
  '',
94
- '- TheGitAI asks before running shell commands or applying file edits.',
96
+ '- In Default mode TheGitAI asks before it creates a file, edits an existing',
97
+ ' file, deletes a file, or runs a command. These are four separate',
98
+ ' permissions: allowing edits does not allow deletions, and allowing file',
99
+ ' changes does not allow commands.',
95
100
  '- At each prompt: **↑/↓** moves between choices, **Enter** confirms the',
96
- ' highlighted one, and **Esc** denies. Deny is selected by default, and the',
97
- ' choices are Approve once, Approve all remaining actions, and Deny.',
101
+ ' highlighted one, and **Esc** denies. Deny is selected by default.',
98
102
  ' Single-letter shortcuts were removed on purpose: a prompt can appear',
99
103
  ' while you are typing, and a stray letter must never approve anything.',
104
+ '- Every prompt offers Approve once, an "Always allow" for that kind of',
105
+ ' action, and Deny. A command also offers to remember just its prefix — for',
106
+ ' example approving `npm test -- --watch=false` can allow `npm test`.',
107
+ '- A prefix is only offered when the command shows a plain verb, as in',
108
+ ' `npm test` or `git status`. Commands with no verb (`ls -la`), interpreters',
109
+ ' and wrappers (`bash -c`, `python -c`, `env`, `timeout`, `make`), and',
110
+ ' destructive or network binaries (`rm`, `sudo`, `curl`) are never offered',
111
+ ' one — a bare binary grant would mean "anything this program can do".',
112
+ ' `npm run` must name its script, so the grant is `npm run build`.',
113
+ '- A remembered prefix never covers a command that chains, pipes, redirects,',
114
+ ' or substitutes, so allowing `npm test` can never green-light',
115
+ ' `npm test && rm -rf build`.',
116
+ '- Everything you allow lasts for the current session only and is forgotten',
117
+ ' when it ends. `/new` clears it immediately.',
100
118
  '- If an approved `sudo` command needs a password, the TUI shows the exact',
101
119
  ' command and keeps the password masked and local.',
102
120
  '- `-y` / `--yes` at startup auto-approves every shell command and file',
@@ -0,0 +1,243 @@
1
+ import { getUnquotedShellText } from './agent-mode.js';
2
+ export const PERMISSION_BUCKETS = ['create', 'edit', 'delete', 'run'];
3
+ export function createSessionGrants() {
4
+ return { buckets: [], commandPrefixes: [] };
5
+ }
6
+ export function bucketActionLabel(bucket) {
7
+ if (bucket === 'create')
8
+ return 'create files';
9
+ if (bucket === 'edit')
10
+ return 'edit files';
11
+ if (bucket === 'delete')
12
+ return 'delete files';
13
+ return 'run commands';
14
+ }
15
+ const NO_PREFIX_GRANT_BINARIES = new Set([
16
+ 'rm',
17
+ 'rmdir',
18
+ 'mv',
19
+ 'dd',
20
+ 'mkfs',
21
+ 'shred',
22
+ 'sudo',
23
+ 'doas',
24
+ 'su',
25
+ 'chmod',
26
+ 'chown',
27
+ 'chgrp',
28
+ 'kill',
29
+ 'pkill',
30
+ 'killall',
31
+ 'shutdown',
32
+ 'reboot',
33
+ 'curl',
34
+ 'wget',
35
+ 'ssh',
36
+ 'scp',
37
+ 'nc',
38
+ 'eval',
39
+ 'exec',
40
+ 'source',
41
+ ]);
42
+ const INTERPRETER_OR_WRAPPER_BINARIES = new Set([
43
+ 'bash',
44
+ 'sh',
45
+ 'zsh',
46
+ 'fish',
47
+ 'dash',
48
+ 'ksh',
49
+ 'csh',
50
+ 'tcsh',
51
+ 'python',
52
+ 'python2',
53
+ 'python3',
54
+ 'node',
55
+ 'deno',
56
+ 'bun',
57
+ 'ruby',
58
+ 'perl',
59
+ 'php',
60
+ 'lua',
61
+ 'osascript',
62
+ 'awk',
63
+ 'gawk',
64
+ 'sed',
65
+ 'env',
66
+ 'timeout',
67
+ 'xargs',
68
+ 'nohup',
69
+ 'setsid',
70
+ 'watch',
71
+ 'script',
72
+ 'nice',
73
+ 'ionice',
74
+ 'stdbuf',
75
+ 'time',
76
+ 'sudo',
77
+ 'doas',
78
+ 'su',
79
+ 'find',
80
+ 'make',
81
+ ]);
82
+ const SCRIPT_RUNNER_VERBS = new Set(['run', 'exec', 'run-script', 'x', 'dlx']);
83
+ const VERB_PATTERN = /^[A-Za-z][A-Za-z0-9_:-]*$/;
84
+ const BINARY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
85
+ const SHELL_CONTROL_OPERATOR_PATTERN = /[&|;<>\n]/;
86
+ const COMMAND_SUBSTITUTION_PATTERN = /`|\$\(/;
87
+ function normalizeCommand(command) {
88
+ return String(command ?? '').trim().replace(/\s+/g, ' ');
89
+ }
90
+ function parseCommand(command) {
91
+ const raw = String(command ?? '');
92
+ if (!raw.trim())
93
+ return null;
94
+ if (COMMAND_SUBSTITUTION_PATTERN.test(raw))
95
+ return null;
96
+ if (SHELL_CONTROL_OPERATOR_PATTERN.test(getUnquotedShellText(raw)))
97
+ return null;
98
+ const all = normalizeCommand(raw).match(/\S+/g) ?? [];
99
+ const tokens = [];
100
+ for (const token of all) {
101
+ if (!tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
102
+ continue;
103
+ if (!tokens.length && token === 'command')
104
+ continue;
105
+ tokens.push(token);
106
+ }
107
+ if (!tokens.length)
108
+ return null;
109
+ const binary = (tokens[0].split('/').pop() ?? '').trim();
110
+ if (!binary)
111
+ return null;
112
+ tokens[0] = binary;
113
+ return { canonical: tokens.join(' '), tokens };
114
+ }
115
+ function grantPrefixFromTokens(tokens) {
116
+ const binary = tokens[0];
117
+ if (!BINARY_PATTERN.test(binary))
118
+ return null;
119
+ if (NO_PREFIX_GRANT_BINARIES.has(binary))
120
+ return null;
121
+ if (INTERPRETER_OR_WRAPPER_BINARIES.has(binary))
122
+ return null;
123
+ const verb = tokens[1];
124
+ if (!verb || !VERB_PATTERN.test(verb))
125
+ return null;
126
+ if (SCRIPT_RUNNER_VERBS.has(verb)) {
127
+ const script = tokens[2];
128
+ if (!script || !VERB_PATTERN.test(script))
129
+ return null;
130
+ return `${binary} ${verb} ${script}`;
131
+ }
132
+ return `${binary} ${verb}`;
133
+ }
134
+ export function commandGrantPrefix(command) {
135
+ const parsed = parseCommand(command);
136
+ return parsed ? grantPrefixFromTokens(parsed.tokens) : null;
137
+ }
138
+ export function isBucketGranted(grants, bucket) {
139
+ return Boolean(grants?.buckets.includes(bucket));
140
+ }
141
+ export function isCommandGranted(grants, command) {
142
+ if (!grants)
143
+ return false;
144
+ if (grants.buckets.includes('run'))
145
+ return true;
146
+ if (!grants.commandPrefixes.length)
147
+ return false;
148
+ const parsed = parseCommand(command);
149
+ if (!parsed)
150
+ return false;
151
+ if (grantPrefixFromTokens(parsed.tokens) === null)
152
+ return false;
153
+ return grants.commandPrefixes.some((prefix) => parsed.canonical === prefix || parsed.canonical.startsWith(`${prefix} `));
154
+ }
155
+ export function grantBucket(grants, bucket) {
156
+ if (!grants.buckets.includes(bucket))
157
+ grants.buckets.push(bucket);
158
+ }
159
+ export function grantCommandPrefix(grants, prefix) {
160
+ const normalized = normalizeCommand(prefix);
161
+ if (!normalized)
162
+ return;
163
+ if (!grants.commandPrefixes.includes(normalized)) {
164
+ grants.commandPrefixes.push(normalized);
165
+ }
166
+ }
167
+ export function buildPermissionOptions(bucket, command) {
168
+ const options = [
169
+ { label: 'Approve once', decision: { kind: 'once' } },
170
+ ];
171
+ const prefix = bucket === 'run' && command ? commandGrantPrefix(command) : null;
172
+ if (prefix) {
173
+ options.push({
174
+ label: `Always allow: ${prefix}`,
175
+ decision: { kind: 'always-prefix', prefix },
176
+ });
177
+ }
178
+ options.push({
179
+ label: `Always allow: ${bucketActionLabel(bucket)}`,
180
+ decision: { kind: 'always-bucket' },
181
+ });
182
+ options.push({ label: 'Deny', decision: { kind: 'deny' } });
183
+ return options;
184
+ }
185
+ function declinedSubject(bucket) {
186
+ if (bucket === 'run') {
187
+ return { noun: 'command', effect: 'Nothing was executed' };
188
+ }
189
+ if (bucket === 'create') {
190
+ return { noun: 'file creation', effect: 'Nothing was created' };
191
+ }
192
+ if (bucket === 'delete') {
193
+ return { noun: 'file deletion', effect: 'Nothing was deleted' };
194
+ }
195
+ return { noun: 'edit', effect: 'Nothing was changed' };
196
+ }
197
+ function declinedResponse(bucket, toolName, extra) {
198
+ const { noun, effect } = declinedSubject(bucket);
199
+ return {
200
+ ok: false,
201
+ skipped: true,
202
+ ...extra,
203
+ failureCategory: 'user_declined',
204
+ failureDetails: {
205
+ category: 'user_declined',
206
+ tool: toolName,
207
+ 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.',
208
+ },
209
+ error: `The real user rejected this proposed ${noun}. ${effect}; this was not a tool failure or an automated system skip.`,
210
+ };
211
+ }
212
+ export async function ensurePermission(context, request, toolName, extra = {}) {
213
+ if (context.autoYes)
214
+ return null;
215
+ if (isBucketGranted(context.grants, request.bucket))
216
+ return null;
217
+ if (request.bucket === 'run' &&
218
+ request.command &&
219
+ isCommandGranted(context.grants, request.command)) {
220
+ return null;
221
+ }
222
+ if (!context.requestPermission) {
223
+ return {
224
+ ok: false,
225
+ ...extra,
226
+ error: `requestPermission is required when autoYes is false (${toolName})`,
227
+ };
228
+ }
229
+ const options = buildPermissionOptions(request.bucket, request.command);
230
+ const decision = await context.requestPermission({ ...request, options });
231
+ if (decision.kind === 'deny') {
232
+ return declinedResponse(request.bucket, toolName, extra);
233
+ }
234
+ if (context.grants) {
235
+ if (decision.kind === 'always-bucket') {
236
+ grantBucket(context.grants, request.bucket);
237
+ }
238
+ else if (decision.kind === 'always-prefix') {
239
+ grantCommandPrefix(context.grants, decision.prefix);
240
+ }
241
+ }
242
+ return null;
243
+ }
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
4
4
  import path from 'node:path';
5
5
  import { getClientStateDir } from './client-state.js';
6
6
  import { normalizeAssistantEditJournal } from './edit-journal.js';
7
+ import { createSessionGrants } from './permissions.js';
7
8
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
8
9
  import { singleLinePreview } from './utils.js';
9
10
  const SESSION_STORE_VERSION = 1;
@@ -211,6 +212,7 @@ export function saveSessionState(session, env = process.env) {
211
212
  }
212
213
  export function applySessionSnapshot(session, snapshot, options = {}) {
213
214
  const currentAgentMode = session.agentMode;
215
+ const previousSessionId = session.sessionId;
214
216
  session.sessionId = snapshot.id;
215
217
  session.sessionName = snapshot.name;
216
218
  session.sessionCreatedAt = snapshot.createdAt;
@@ -220,6 +222,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
220
222
  session.serverState = sanitizeOpaqueState(snapshot.serverState);
221
223
  session.agentMode = options.preserveAgentMode ? currentAgentMode : 'default';
222
224
  session.autoYes = session.agentMode === 'auto-accept';
225
+ if (previousSessionId !== session.sessionId) {
226
+ session.grants = createSessionGrants();
227
+ }
223
228
  session.turnState = {
224
229
  id: null,
225
230
  historyStartIndex: session.history.length,
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { normalizeAgentMode, } from './agent-mode.js';
3
+ import { createSessionGrants, } from './permissions.js';
3
4
  import { createSessionSafetyState, } from './session-safety.js';
4
5
  import { clampInteger } from './utils.js';
5
6
  const DEFAULT_MAX_TOOL_STEPS = 32;
@@ -31,7 +32,7 @@ function preserveProviderSelection(serverState) {
31
32
  : null;
32
33
  return providerSelection ? { providerSelection } : {};
33
34
  }
34
- export function createSession({ rootDir, autoYes = false, agentMode, modelId, maxToolSteps = DEFAULT_MAX_TOOL_STEPS, confirmCommand = null, confirmPatch = null, requestSudoPassword = null, requestUserInput = null, onStatus = null, onContextLog = null, onToolEvent = null, env = process.env, sessionId = createSessionId(), sessionName = null, history = [], serverState = null, editJournal = [], stickyFilePaths = [], editCounter = 0, safety = createSessionSafetyState(), }) {
35
+ export function createSession({ rootDir, autoYes = false, agentMode, modelId, maxToolSteps = DEFAULT_MAX_TOOL_STEPS, requestPermission = null, requestSudoPassword = null, requestUserInput = null, onStatus = null, onContextLog = null, onToolEvent = null, env = process.env, sessionId = createSessionId(), sessionName = null, history = [], serverState = null, editJournal = [], stickyFilePaths = [], editCounter = 0, safety = createSessionSafetyState(), }) {
35
36
  const createdAt = new Date().toISOString();
36
37
  const initialAgentMode = normalizeAgentMode(agentMode ?? (autoYes ? 'auto-accept' : 'default'));
37
38
  return {
@@ -43,8 +44,8 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
43
44
  onStatus: onStatus ?? defaultStatus,
44
45
  onContextLog: onContextLog ?? defaultContextLog,
45
46
  onToolEvent,
46
- confirmCommand,
47
- confirmPatch,
47
+ grants: createSessionGrants(),
48
+ requestPermission,
48
49
  requestSudoPassword,
49
50
  requestUserInput,
50
51
  history: JSON.parse(JSON.stringify(history)),
@@ -80,6 +81,7 @@ export function startNewConversation(session) {
80
81
  }
81
82
  export function clearConversation(session) {
82
83
  session.history = [];
84
+ session.grants = createSessionGrants();
83
85
  session.serverState = preserveProviderSelection(session.serverState);
84
86
  session.turnState = {
85
87
  id: null,
@@ -277,8 +277,8 @@ export async function executeLocalToolCall(toolContext, session, call) {
277
277
  sessionId: session.sessionId,
278
278
  projectIndex: toolContext.projectIndex,
279
279
  autoYes: session.autoYes,
280
- confirmCommand: session.confirmCommand,
281
- confirmPatch: session.confirmPatch,
280
+ grants: session.grants,
281
+ requestPermission: session.requestPermission,
282
282
  requestSudoPassword: session.requestSudoPassword,
283
283
  onStatus: session.onStatus,
284
284
  editJournal: session.clientState.editJournal,
@@ -3,6 +3,7 @@ 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';
6
+ import { ensurePermission } from '../permissions.js';
6
7
  export async function deleteFile(context, args) {
7
8
  const { rootDir, projectIndex } = context;
8
9
  const filePath = String(args.filePath ?? '').trim();
@@ -24,6 +25,19 @@ export async function deleteFile(context, args) {
24
25
  };
25
26
  }
26
27
  const scratchPath = pathKind === 'scratch';
28
+ if (!scratchPath) {
29
+ const denied = await ensurePermission(context, {
30
+ bucket: 'delete',
31
+ title: 'Approve file deletion?',
32
+ body: `Delete ${filePath}`,
33
+ filePath,
34
+ }, 'delete_file', { filePath });
35
+ if (denied) {
36
+ if (!isTuiMode())
37
+ console.log(chalk.dim(` ⏭ Delete skipped: ${filePath}`));
38
+ return denied;
39
+ }
40
+ }
27
41
  const result = deleteProjectFile(rootDir, filePath);
28
42
  if (result.deleted) {
29
43
  if (!scratchPath) {
@@ -7,9 +7,10 @@ import { repairFilePath } from './path-suggest.js';
7
7
  import { isTuiMode } from '../runtime-mode.js';
8
8
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
9
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
10
+ import { ensurePermission } from '../permissions.js';
10
11
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
11
12
  export async function patchFile(context, args) {
12
- const { rootDir, projectIndex, autoYes, confirmPatch } = context;
13
+ const { rootDir, projectIndex } = context;
13
14
  const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
14
15
  let patch = typeof args.patch === 'string' ? args.patch : '';
15
16
  if (!filePath) {
@@ -74,23 +75,18 @@ export async function patchFile(context, args) {
74
75
  };
75
76
  }
76
77
  renderDiffPreview(filePath, patch);
77
- if (!autoYes && confirmPatch) {
78
- const confirmed = await confirmPatch(filePath, patch);
79
- if (!confirmed) {
78
+ if (!scratchPath) {
79
+ const denied = await ensurePermission(context, {
80
+ bucket: getCurrentFileHash(rootDir, filePath) === null ? 'create' : 'edit',
81
+ title: 'Approve patch?',
82
+ body: 'Review changes before applying.',
83
+ filePath,
84
+ diff: patch,
85
+ }, 'patch_file', { filePath });
86
+ if (denied) {
80
87
  if (!isTuiMode())
81
88
  console.log(chalk.dim(` ⏭ Patch skipped: ${filePath}`));
82
- return {
83
- ok: false,
84
- skipped: true,
85
- filePath,
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.',
93
- };
89
+ return denied;
94
90
  }
95
91
  }
96
92
  const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
@@ -6,6 +6,8 @@ import { readCliAuthConfig } from '../api/auth.js';
6
6
  import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
7
7
  import { repairFilePath, suggestClosestPath } from './path-suggest.js';
8
8
  import { isTuiMode } from '../runtime-mode.js';
9
+ import { ensurePermission } from '../permissions.js';
10
+ import { getCurrentFileHash } from '../session-safety.js';
9
11
  function normalizeReplacements(value) {
10
12
  if (!Array.isArray(value))
11
13
  return [];
@@ -178,27 +180,35 @@ export async function replaceDocumentText(context, args) {
178
180
  }
179
181
  const preview = String(serverResult.preview ?? '');
180
182
  renderPreview(targetPath, preview);
181
- if (!context.autoYes && context.confirmPatch) {
182
- const confirmed = await context.confirmPatch(targetPath, preview);
183
- if (!confirmed) {
184
- if (!isTuiMode()) {
185
- console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
186
- }
187
- return {
188
- ok: false,
189
- skipped: true,
190
- filePath: targetPath,
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.',
198
- };
183
+ const targetHashBeforeApproval = getCurrentFileHash(context.rootDir, targetPath);
184
+ const documentIsNew = targetHashBeforeApproval === null;
185
+ const denied = await ensurePermission(context, {
186
+ bucket: documentIsNew ? 'create' : 'edit',
187
+ title: documentIsNew ? 'Approve new document?' : 'Approve document edit?',
188
+ body: 'Review changes before applying.',
189
+ filePath: targetPath,
190
+ diff: preview,
191
+ }, 'replace_document_text', { filePath: targetPath });
192
+ if (denied) {
193
+ if (!isTuiMode()) {
194
+ console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
199
195
  }
196
+ return denied;
200
197
  }
201
198
  const fileData = String(serverResult.fileData ?? '');
199
+ if (getCurrentFileHash(context.rootDir, targetPath) !== targetHashBeforeApproval) {
200
+ return {
201
+ ok: false,
202
+ filePath: targetPath,
203
+ failureCategory: 'conflict',
204
+ error: `replace_document_text refused: ${targetPath} changed on disk while the approval prompt was open.`,
205
+ failureDetails: {
206
+ category: 'conflict',
207
+ tool: 'replace_document_text',
208
+ action: 'Re-read the document to see its current contents, then rebuild the replacements against it and retry.',
209
+ },
210
+ };
211
+ }
202
212
  const nextData = Buffer.from(fileData, 'base64');
203
213
  const write = writeProjectFileBuffer(context.rootDir, targetPath, nextData);
204
214
  const failedCount = Number(serverResult.failedCount ?? 0);
@@ -2,13 +2,14 @@ import chalk from '../colors.js';
2
2
  import { startBackgroundJob } from '../background-jobs.js';
3
3
  import { getBlockedCommandReason, runCommand, } from '../executor.js';
4
4
  import { syncIndexFromDisk } from '../project-index.js';
5
+ import { ensurePermission } from '../permissions.js';
5
6
  import { isTuiMode } from '../runtime-mode.js';
6
7
  import { redactConnectionStringCredentials } from '../secret-preview.js';
7
8
  import { buildNestedGitHint } from '../session-safety.js';
8
9
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
9
10
  const MAX_OUTPUT_CHARS = 4000;
10
11
  export async function runShellCommand(context, args) {
11
- const { rootDir, projectIndex, autoYes, confirmCommand, requestSudoPassword, onStatus, } = context;
12
+ const { rootDir, projectIndex, requestSudoPassword, onStatus, } = context;
12
13
  const command = String(args.command ?? '').trim();
13
14
  if (!command) {
14
15
  return { ok: false, error: 'command is required' };
@@ -31,33 +32,18 @@ export async function runShellCommand(context, args) {
31
32
  }
32
33
  if (!isTuiMode())
33
34
  console.log(chalk.bold.yellow(`\n ⚡ Command: ${command}`));
34
- if (!autoYes) {
35
- if (confirmCommand == null) {
36
- return {
37
- ok: false,
38
- command,
39
- error: 'confirmCommand is required when autoYes is false',
40
- };
41
- }
42
- const approved = await confirmCommand(runInBackground
35
+ const denied = await ensurePermission(context, {
36
+ bucket: 'run',
37
+ title: 'Approve command?',
38
+ body: runInBackground
43
39
  ? `${command}\n\nRuns as a managed background job until it exits or is killed.`
44
- : command);
45
- if (!approved) {
46
- if (!isTuiMode())
47
- console.log(chalk.dim(` ⏭ Skipped: ${command}`));
48
- return {
49
- ok: false,
50
- skipped: true,
51
- command,
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.',
59
- };
60
- }
40
+ : command,
41
+ command,
42
+ }, 'run_command', { command });
43
+ if (denied) {
44
+ if (!isTuiMode())
45
+ console.log(chalk.dim(` ⏭ Skipped: ${command}`));
46
+ return denied;
61
47
  }
62
48
  if (runInBackground) {
63
49
  return runBackgroundCommand(context, command, args.timeout_ms, repoHint);
@@ -1,5 +1,6 @@
1
1
  import chalk from '../colors.js';
2
2
  import { execFileSync, spawn } from 'node:child_process';
3
+ import { ensurePermission } from '../permissions.js';
3
4
  import { syncIndexFromDisk } from '../project-index.js';
4
5
  import { isTuiMode } from '../runtime-mode.js';
5
6
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
@@ -148,7 +149,7 @@ function executeNodeScript(rootDir, script, timeout) {
148
149
  });
149
150
  }
150
151
  export async function runNodeScript(context, args) {
151
- const { rootDir, autoYes, confirmCommand } = context;
152
+ const { rootDir } = context;
152
153
  const script = typeof args.script === 'string' ? args.script : '';
153
154
  if (!script.trim()) {
154
155
  return { ok: false, error: 'script is required' };
@@ -158,32 +159,16 @@ export async function runNodeScript(context, args) {
158
159
  console.log(chalk.bold.yellow(`\n ⚡ Node script:\n${commandForApproval}\n`));
159
160
  console.log(chalk.dim(` in: ${rootDir}\n`));
160
161
  }
161
- if (!autoYes) {
162
- if (confirmCommand == null) {
163
- return {
164
- ok: false,
165
- command: COMMAND_LABEL,
166
- error: 'confirmCommand is required when autoYes is false',
167
- };
168
- }
169
- const approved = await confirmCommand(commandForApproval);
170
- if (!approved) {
171
- if (!isTuiMode()) {
172
- console.log(chalk.dim(` ⏭ Skipped: ${COMMAND_LABEL}`));
173
- }
174
- return {
175
- ok: false,
176
- skipped: true,
177
- command: COMMAND_LABEL,
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.',
185
- };
162
+ const denied = await ensurePermission(context, {
163
+ bucket: 'run',
164
+ title: 'Approve node script?',
165
+ body: commandForApproval,
166
+ }, 'run_node_script', { command: COMMAND_LABEL });
167
+ if (denied) {
168
+ if (!isTuiMode()) {
169
+ console.log(chalk.dim(` ⏭ Skipped: ${COMMAND_LABEL}`));
186
170
  }
171
+ return denied;
187
172
  }
188
173
  const beforeGitStatus = readGitStatusSignature(rootDir);
189
174
  const result = await executeNodeScript(rootDir, script, typeof args.timeout_ms === 'number' && args.timeout_ms > 0
@@ -7,6 +7,7 @@ import { upsertIndexFile } from '../project-index.js';
7
7
  import { isTuiMode } from '../runtime-mode.js';
8
8
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
9
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
10
+ import { ensurePermission } from '../permissions.js';
10
11
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
11
12
  function countOccurrences(haystack, needle) {
12
13
  if (needle.length === 0)
@@ -76,7 +77,7 @@ function buildStrReplacePreview(oldString, newString) {
76
77
  return `@@ str_replace @@\n${minus}\n${plus}`;
77
78
  }
78
79
  export async function strReplace(context, args) {
79
- const { rootDir, projectIndex, autoYes, confirmPatch } = context;
80
+ const { rootDir, projectIndex } = context;
80
81
  const filePath = repairFilePath(rootDir, String(args.filePath ?? args.file_path ?? '').trim());
81
82
  let oldString = typeof args.old_string === 'string'
82
83
  ? args.old_string
@@ -175,23 +176,18 @@ export async function strReplace(context, args) {
175
176
  }
176
177
  console.log();
177
178
  }
178
- if (!autoYes && confirmPatch) {
179
- const confirmed = await confirmPatch(filePath, preview);
180
- if (!confirmed) {
179
+ if (!scratchPath) {
180
+ const denied = await ensurePermission(context, {
181
+ bucket: 'edit',
182
+ title: 'Approve patch?',
183
+ body: 'Review changes before applying.',
184
+ filePath,
185
+ diff: preview,
186
+ }, 'str_replace', { filePath });
187
+ if (denied) {
181
188
  if (!isTuiMode())
182
189
  console.log(chalk.dim(` ⏭ str_replace skipped: ${filePath}`));
183
- return {
184
- ok: false,
185
- skipped: true,
186
- filePath,
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.',
194
- };
190
+ return denied;
195
191
  }
196
192
  }
197
193
  const nextContent = originalContent.split(oldString).join(newString);
@@ -2,11 +2,47 @@ import chalk from '../colors.js';
2
2
  import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
4
  import { classifyProjectPath, writeProjectFile } from '../patcher.js';
5
+ import { readFileEditSnapshot } from '../edit-journal.js';
5
6
  import { upsertIndexFile } from '../project-index.js';
6
7
  import { isTuiMode } from '../runtime-mode.js';
7
8
  import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
8
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
10
+ import { ensurePermission } from '../permissions.js';
9
11
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
12
+ const MAX_WRITE_PREVIEW_LINES = 400;
13
+ function boundedLines(text, limit) {
14
+ const lines = [];
15
+ let omitted = 0;
16
+ let start = 0;
17
+ for (;;) {
18
+ const newline = text.indexOf('\n', start);
19
+ if (lines.length < limit) {
20
+ lines.push(text.slice(start, newline === -1 ? undefined : newline));
21
+ }
22
+ else {
23
+ omitted += 1;
24
+ }
25
+ if (newline === -1)
26
+ break;
27
+ start = newline + 1;
28
+ }
29
+ return { lines, omitted };
30
+ }
31
+ function buildWritePreview(previous, next) {
32
+ const rows = ['@@ write_file @@'];
33
+ const append = (text, sign, marker) => {
34
+ const { lines, omitted } = boundedLines(text, MAX_WRITE_PREVIEW_LINES);
35
+ for (const line of lines)
36
+ rows.push(`${sign}${line}`);
37
+ if (omitted > 0) {
38
+ rows.push(`@@ ${omitted} more ${marker} line(s) not shown @@`);
39
+ }
40
+ };
41
+ if (previous !== null)
42
+ append(previous, '-', 'removed');
43
+ append(next, '+', 'added');
44
+ return rows.join('\n');
45
+ }
10
46
  export async function writeFile(context, args) {
11
47
  const { rootDir, projectIndex } = context;
12
48
  const filePath = String(args.filePath ?? '').trim();
@@ -60,6 +96,36 @@ export async function writeFile(context, args) {
60
96
  };
61
97
  }
62
98
  content = resolveRedactionTokens(context.safety, content, coveragePath, currentHash);
99
+ if (!scratchPath) {
100
+ const before = currentHash === null ? null : readFileEditSnapshot(rootDir, filePath);
101
+ const existing = before && before.contentEncoding === 'utf8' ? before.content : null;
102
+ const denied = await ensurePermission(context, {
103
+ bucket: currentHash === null ? 'create' : 'edit',
104
+ title: currentHash === null ? 'Approve new file?' : 'Approve patch?',
105
+ body: 'Review changes before applying.',
106
+ filePath,
107
+ diff: buildWritePreview(existing, content),
108
+ }, 'write_file', { filePath });
109
+ if (denied) {
110
+ if (!isTuiMode())
111
+ console.log(chalk.dim(` ⏭ write_file skipped: ${filePath}`));
112
+ return denied;
113
+ }
114
+ if (getCurrentFileHash(rootDir, filePath) !== currentHash) {
115
+ return {
116
+ ok: false,
117
+ filePath,
118
+ failureCategory: 'conflict',
119
+ error: `write_file refused: ${filePath} changed on disk while the approval prompt was open.`,
120
+ failureDetails: {
121
+ category: 'conflict',
122
+ tool: 'write_file',
123
+ action: 'Re-read the file to see its current contents, then decide whether the write is still correct and retry.',
124
+ },
125
+ currentHash,
126
+ };
127
+ }
128
+ }
63
129
  const { changed } = writeProjectFile(rootDir, filePath, content);
64
130
  let indexedChunks = 0;
65
131
  let retrievalTokensUsed = 0;
@@ -1,4 +1,5 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
+ import { bucketActionLabel, } from '../permissions.js';
2
3
  import { approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, userInputViewportForFrame, } from './tui/build-frame.js';
3
4
  import { createTerminalTitleController } from './tui/terminal-title.js';
4
5
  import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
@@ -74,23 +75,6 @@ const WORKING_TOOL_PREVIEW_ITEMS = 4;
74
75
  const THINKING_NOTE_PREVIEW_ROWS = 3;
75
76
  const WORKING_TOOL_PREVIEW_ROWS = 3;
76
77
  const AGENT_MODE_LABEL_WIDTH = 16;
77
- const APPROVAL_OPTIONS = [
78
- {
79
- value: 'y',
80
- label: 'Approve once',
81
- description: 'Run this action only this time',
82
- },
83
- {
84
- value: 'a',
85
- label: 'Approve all remaining actions',
86
- description: 'Turn on auto-approve for the rest of the session',
87
- },
88
- {
89
- value: 'n',
90
- label: 'Deny',
91
- description: 'Reject this action',
92
- },
93
- ];
94
78
  export const SLASH_COMMANDS = [
95
79
  {
96
80
  command: '/help',
@@ -995,7 +979,7 @@ function createInitialShellState(session, serverModels, debugUi) {
995
979
  activeTurnInputPreformatted: false,
996
980
  agentMode: session.agentMode,
997
981
  analyzingImages: 0,
998
- approvalCursor: getDefaultApprovalCursor(),
982
+ approvalCursor: 0,
999
983
  approvalPrompt: null,
1000
984
  approvalScrollOffset: 0,
1001
985
  autoYes: session.autoYes,
@@ -1279,16 +1263,13 @@ export function navigatePromptHistory(state, direction) {
1279
1263
  promptHistoryCursor: nextCursor,
1280
1264
  };
1281
1265
  }
1282
- function getDefaultApprovalCursor() {
1283
- const denyIndex = APPROVAL_OPTIONS.findIndex((option) => option.value === 'n');
1284
- return denyIndex === -1 ? 0 : denyIndex;
1285
- }
1286
- export function getNextApprovalCursor(currentIndex, direction) {
1287
- return (currentIndex + direction + APPROVAL_OPTIONS.length) % APPROVAL_OPTIONS.length;
1266
+ export function getDefaultApprovalCursor(optionCount) {
1267
+ return Math.max(0, optionCount - 1);
1288
1268
  }
1289
- export function getApprovalChoiceForCursor(cursor) {
1290
- return (APPROVAL_OPTIONS[Math.min(Math.max(cursor, 0), APPROVAL_OPTIONS.length - 1)]
1291
- ?.value ?? 'n');
1269
+ export function getNextApprovalCursor(currentIndex, direction, optionCount) {
1270
+ if (optionCount <= 0)
1271
+ return 0;
1272
+ return (currentIndex + direction + optionCount) % optionCount;
1292
1273
  }
1293
1274
  export function pauseBusyClock(state, nowMs) {
1294
1275
  if (state.busySince === null || state.busyPausedAt !== null)
@@ -1372,7 +1353,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1372
1353
  let currentServerModels = serverModels;
1373
1354
  let sessionAutoYes = session.autoYes;
1374
1355
  let resolveDone = null;
1375
- let resolveApprovalChoice = null;
1356
+ let resolvePermissionDecision = null;
1376
1357
  let resolveSudoPassword = null;
1377
1358
  let pendingUserInput = null;
1378
1359
  let cleanupSudoPasswordPrompt = null;
@@ -1598,14 +1579,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1598
1579
  }
1599
1580
  };
1600
1581
  const dismissPendingApproval = () => {
1601
- if (!resolveApprovalChoice)
1582
+ if (!resolvePermissionDecision)
1602
1583
  return;
1603
- const pendingResolve = resolveApprovalChoice;
1604
- resolveApprovalChoice = null;
1605
- pendingResolve('n');
1584
+ const pendingResolve = resolvePermissionDecision;
1585
+ resolvePermissionDecision = null;
1586
+ pendingResolve({ kind: 'deny' });
1606
1587
  store.update((current) => ({
1607
1588
  ...resumeBusyClock(current, Date.now()),
1608
- approvalCursor: getDefaultApprovalCursor(),
1589
+ approvalCursor: 0,
1609
1590
  approvalPrompt: null,
1610
1591
  approvalScrollOffset: 0,
1611
1592
  }));
@@ -1929,41 +1910,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1929
1910
  pending.resolve(result);
1930
1911
  scheduleLiveFrameRemount();
1931
1912
  };
1932
- const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1913
+ const openApprovalPrompt = (request) => new Promise((resolve) => {
1914
+ const deny = { kind: 'deny' };
1933
1915
  if (exiting) {
1934
- resolve('n');
1916
+ resolve(deny);
1935
1917
  return;
1936
1918
  }
1937
- resolveApprovalChoice = resolve;
1919
+ resolvePermissionDecision = resolve;
1938
1920
  store.update((current) => ({
1939
1921
  ...pauseBusyClock(current, Date.now()),
1940
- approvalCursor: getDefaultApprovalCursor(),
1922
+ approvalCursor: getDefaultApprovalCursor(request.options.length),
1941
1923
  approvalOpenedAt: Date.now(),
1942
1924
  approvalScrollOffset: 0,
1943
1925
  approvalPrompt: {
1944
- title,
1945
- body,
1946
- diffPreview: options.diff && options.filePath
1947
- ? parseDiffPreview(options.diff)
1926
+ title: request.title,
1927
+ body: request.body,
1928
+ diffPreview: request.diff && request.filePath
1929
+ ? parseDiffPreview(request.diff)
1948
1930
  : undefined,
1949
- filePath: options.filePath,
1931
+ filePath: request.filePath,
1932
+ options: request.options,
1950
1933
  returnStatus: current.status,
1951
1934
  },
1952
- status: title,
1935
+ status: request.title,
1953
1936
  }));
1954
1937
  });
1955
- const handleInlineApprovalChoice = async (choice) => {
1938
+ const handleInlineApprovalChoice = async (index) => {
1956
1939
  const current = store.getState();
1957
- const pendingResolve = resolveApprovalChoice;
1958
- resolveApprovalChoice = null;
1940
+ const options = current.approvalPrompt?.options ?? [];
1941
+ const chosen = options[index]?.decision ?? { kind: 'deny' };
1942
+ const pendingResolve = resolvePermissionDecision;
1943
+ resolvePermissionDecision = null;
1959
1944
  store.update((next) => ({
1960
1945
  ...resumeBusyClock(next, Date.now()),
1961
- approvalCursor: getDefaultApprovalCursor(),
1946
+ approvalCursor: getDefaultApprovalCursor(options.length),
1962
1947
  approvalPrompt: null,
1963
1948
  approvalScrollOffset: 0,
1964
1949
  status: current.approvalPrompt?.returnStatus ?? next.status,
1965
1950
  }));
1966
- pendingResolve?.(choice);
1951
+ pendingResolve?.(chosen);
1967
1952
  };
1968
1953
  const refreshServerModels = async () => {
1969
1954
  currentServerModels = await models.fetchServerModels({ config: authConfig });
@@ -2872,44 +2857,28 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2872
2857
  projectIndex.onContextLog = session.onContextLog;
2873
2858
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2874
2859
  session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
2875
- session.confirmCommand = async (command) => {
2860
+ session.requestPermission = async (request) => {
2861
+ const deny = { kind: 'deny' };
2876
2862
  if (exiting)
2877
- return false;
2863
+ return deny;
2878
2864
  if (sessionAutoYes)
2879
- return true;
2880
- const choice = await openApprovalPrompt('Approve command?', command);
2881
- if (choice === 'a') {
2882
- setAgentMode('auto-accept');
2883
- syncShellStateFromSession();
2865
+ return { kind: 'once' };
2866
+ const decision = await openApprovalPrompt(request);
2867
+ if (decision.kind === 'always-bucket') {
2884
2868
  appendTurnAwareEntry({
2885
- body: 'Auto-approve enabled for the rest of this session.',
2869
+ body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
2886
2870
  kind: 'system',
2887
2871
  title: 'Approvals',
2888
2872
  });
2889
- return true;
2890
2873
  }
2891
- return choice === 'y';
2892
- };
2893
- session.confirmPatch = async (filePath, patch) => {
2894
- if (exiting)
2895
- return false;
2896
- if (sessionAutoYes)
2897
- return true;
2898
- const choice = await openApprovalPrompt('Approve patch?', 'Review changes before applying.', {
2899
- diff: patch,
2900
- filePath,
2901
- });
2902
- if (choice === 'a') {
2903
- setAgentMode('auto-accept');
2904
- syncShellStateFromSession();
2874
+ else if (decision.kind === 'always-prefix') {
2905
2875
  appendTurnAwareEntry({
2906
- body: 'Auto-approve enabled for the rest of this session.',
2876
+ body: `Allowed for the rest of this session: commands starting with \`${decision.prefix}\`.`,
2907
2877
  kind: 'system',
2908
2878
  title: 'Approvals',
2909
2879
  });
2910
- return true;
2911
2880
  }
2912
- return choice === 'y';
2881
+ return decision;
2913
2882
  };
2914
2883
  const shellInputHandlers = {
2915
2884
  getApprovalScrollLimit: () => {
@@ -768,7 +768,6 @@ function modelPickerPanelSideLine(content, innerWidth) {
768
768
  }
769
769
  const MODEL_PICKER_COST_WIDTH = 6;
770
770
  const MODEL_PICKER_MODEL_WIDTH = 42;
771
- const MODEL_PICKER_NUMBER_WIDTH = 4;
772
771
  const MODEL_PICKER_SEPARATOR = ' │ ';
773
772
  const MODEL_PICKER_WIDE_MIN_WIDTH = 78;
774
773
  function modelPickerTopBorder(panelWidth) {
@@ -795,7 +794,6 @@ function modelPickerCostText(rating) {
795
794
  }
796
795
  function modelPickerNotesWidth(innerWidth) {
797
796
  return Math.max(18, innerWidth -
798
- MODEL_PICKER_NUMBER_WIDTH -
799
797
  MODEL_PICKER_MODEL_WIDTH -
800
798
  MODEL_PICKER_COST_WIDTH -
801
799
  MODEL_PICKER_SEPARATOR.length * 2);
@@ -818,12 +816,10 @@ function modelPickerItemLines(option, selected, innerWidth) {
818
816
  : selected
819
817
  ? { color: 'cyan', bold: true }
820
818
  : {};
821
- const numberCell = modelPickerCell(String(option.publicId), MODEL_PICKER_NUMBER_WIDTH, { color: 'cyan', bold: selected, ...selectedStyle });
822
819
  const cost = modelPickerCostText(option.costRating);
823
820
  if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
824
- const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_NUMBER_WIDTH - MODEL_PICKER_COST_WIDTH - 2);
821
+ const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_COST_WIDTH - 2);
825
822
  const row = [
826
- numberCell,
827
823
  ...modelPickerModelSpans(option, selected, modelWidth, false, labelStyle, selectedStyle),
828
824
  span(' ', selectedStyle),
829
825
  modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
@@ -833,7 +829,6 @@ function modelPickerItemLines(option, selected, innerWidth) {
833
829
  ];
834
830
  }
835
831
  const row = [
836
- numberCell,
837
832
  ...modelPickerModelSpans(option, selected, MODEL_PICKER_MODEL_WIDTH, true, labelStyle, selectedStyle),
838
833
  span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
839
834
  modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
@@ -850,9 +845,9 @@ function modelPickerItemLines(option, selected, innerWidth) {
850
845
  function modelPickerHeaderLine(innerWidth) {
851
846
  const heading = { color: MODEL_PICKER_ACCENT_COLOR, bold: true };
852
847
  if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
853
- return line(modelPickerCell('#', MODEL_PICKER_NUMBER_WIDTH, heading), modelPickerCell('Model', Math.max(1, innerWidth - MODEL_PICKER_NUMBER_WIDTH), heading));
848
+ return line(modelPickerCell('Model', Math.max(1, innerWidth), heading));
854
849
  }
855
- 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));
850
+ return line(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));
856
851
  }
857
852
  function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
858
853
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
@@ -1100,7 +1095,7 @@ function buildOverlayLines(state, width, height, nowMs) {
1100
1095
  if (padded)
1101
1096
  lines.push(plainLine(''));
1102
1097
  }
1103
- const options = ['Approve once', 'Approve all remaining actions', 'Deny'];
1098
+ const options = (prompt.options ?? []).map((option) => option.label);
1104
1099
  options.forEach((label, index) => {
1105
1100
  const selected = index === state.approvalCursor;
1106
1101
  lines.push(line(span(selected ? '› ' : ' ', {
@@ -1,5 +1,5 @@
1
1
  import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
2
- import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getApprovalChoiceForCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
2
+ import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
3
3
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
4
4
  import { handleUserInputPromptEvent, } from './user-input.js';
5
5
  const APPROVAL_PREVIEW_PAGE_ROWS = 3;
@@ -293,7 +293,7 @@ export function handleShellKeyEvent(store, handlers, event) {
293
293
  if (key.upArrow || key.downArrow) {
294
294
  store.update((current) => ({
295
295
  ...current,
296
- approvalCursor: getNextApprovalCursor(current.approvalCursor, key.upArrow ? -1 : 1),
296
+ approvalCursor: getNextApprovalCursor(current.approvalCursor, key.upArrow ? -1 : 1, current.approvalPrompt?.options?.length ?? 0),
297
297
  }));
298
298
  return;
299
299
  }
@@ -301,11 +301,11 @@ export function handleShellKeyEvent(store, handlers, event) {
301
301
  return;
302
302
  }
303
303
  if (key.escape) {
304
- void handlers.onResolveApproval('n');
304
+ void handlers.onResolveApproval(-1);
305
305
  return;
306
306
  }
307
307
  if (key.returnKey) {
308
- void handlers.onResolveApproval(getApprovalChoiceForCursor(state.approvalCursor));
308
+ void handlers.onResolveApproval(state.approvalCursor);
309
309
  }
310
310
  return;
311
311
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.16",
3
+ "version": "1.0.0-preview.18",
4
4
  "description": "TheGitAI is an AI coding agent for your terminal. It indexes your repository, writes and edits files, runs commands, and builds features with you.",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,10 +37,10 @@
37
37
  "@lydell/node-pty-linux-x64": "1.1.0",
38
38
  "@lydell/node-pty-win32-arm64": "1.1.0",
39
39
  "@lydell/node-pty-win32-x64": "1.1.0",
40
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.16",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.16",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.16",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.16",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.18",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.18",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.18",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.18",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {