@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.
- package/dist/src/agent-mode.js +1 -1
- package/dist/src/help-text.js +22 -4
- package/dist/src/permissions.js +243 -0
- package/dist/src/session-store.js +5 -0
- package/dist/src/session.js +5 -3
- package/dist/src/tool-executor.js +2 -2
- package/dist/src/tools/delete-file.js +14 -0
- package/dist/src/tools/patch-file.js +12 -16
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/run-command.js +13 -27
- package/dist/src/tools/run-node-script.js +11 -26
- package/dist/src/tools/str-replace.js +12 -16
- package/dist/src/tools/write-file.js +66 -0
- package/dist/src/ui/repl.js +43 -74
- package/dist/src/ui/tui/build-frame.js +4 -9
- package/dist/src/ui/tui/shell-input.js +4 -4
- package/package.json +5 -5
package/dist/src/agent-mode.js
CHANGED
|
@@ -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 = '';
|
package/dist/src/help-text.js
CHANGED
|
@@ -47,7 +47,9 @@ const HELP_MARKDOWN = [
|
|
|
47
47
|
'',
|
|
48
48
|
'## Modes',
|
|
49
49
|
'',
|
|
50
|
-
'- Default — asks before
|
|
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
|
|
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
|
|
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,
|
package/dist/src/session.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
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 (!
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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 (!
|
|
179
|
-
const
|
|
180
|
-
|
|
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;
|
package/dist/src/ui/repl.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
|
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 (!
|
|
1582
|
+
if (!resolvePermissionDecision)
|
|
1602
1583
|
return;
|
|
1603
|
-
const pendingResolve =
|
|
1604
|
-
|
|
1605
|
-
pendingResolve('
|
|
1584
|
+
const pendingResolve = resolvePermissionDecision;
|
|
1585
|
+
resolvePermissionDecision = null;
|
|
1586
|
+
pendingResolve({ kind: 'deny' });
|
|
1606
1587
|
store.update((current) => ({
|
|
1607
1588
|
...resumeBusyClock(current, Date.now()),
|
|
1608
|
-
approvalCursor:
|
|
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 = (
|
|
1913
|
+
const openApprovalPrompt = (request) => new Promise((resolve) => {
|
|
1914
|
+
const deny = { kind: 'deny' };
|
|
1933
1915
|
if (exiting) {
|
|
1934
|
-
resolve(
|
|
1916
|
+
resolve(deny);
|
|
1935
1917
|
return;
|
|
1936
1918
|
}
|
|
1937
|
-
|
|
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:
|
|
1947
|
-
? parseDiffPreview(
|
|
1926
|
+
title: request.title,
|
|
1927
|
+
body: request.body,
|
|
1928
|
+
diffPreview: request.diff && request.filePath
|
|
1929
|
+
? parseDiffPreview(request.diff)
|
|
1948
1930
|
: undefined,
|
|
1949
|
-
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 (
|
|
1938
|
+
const handleInlineApprovalChoice = async (index) => {
|
|
1956
1939
|
const current = store.getState();
|
|
1957
|
-
const
|
|
1958
|
-
|
|
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?.(
|
|
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.
|
|
2860
|
+
session.requestPermission = async (request) => {
|
|
2861
|
+
const deny = { kind: 'deny' };
|
|
2876
2862
|
if (exiting)
|
|
2877
|
-
return
|
|
2863
|
+
return deny;
|
|
2878
2864
|
if (sessionAutoYes)
|
|
2879
|
-
return
|
|
2880
|
-
const
|
|
2881
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 -
|
|
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('
|
|
848
|
+
return line(modelPickerCell('Model', Math.max(1, innerWidth), heading));
|
|
854
849
|
}
|
|
855
|
-
return line(modelPickerCell('
|
|
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 =
|
|
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,
|
|
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(
|
|
304
|
+
void handlers.onResolveApproval(-1);
|
|
305
305
|
return;
|
|
306
306
|
}
|
|
307
307
|
if (key.returnKey) {
|
|
308
|
-
void handlers.onResolveApproval(
|
|
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.
|
|
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.
|
|
41
|
-
"@thegitai/tui-darwin-x64": "1.0.0-preview.
|
|
42
|
-
"@thegitai/tui-linux-x64": "1.0.0-preview.
|
|
43
|
-
"@thegitai/tui-win32-x64": "1.0.0-preview.
|
|
40
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-preview.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": {
|