@thegitai/cli 1.0.0-preview.15 → 1.0.0-preview.17
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/api/chat.js +34 -2
- package/dist/src/help-text.js +32 -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 +206 -85
- package/dist/src/ui/tui/build-frame.js +54 -16
- package/dist/src/ui/tui/shell-input.js +27 -13
- package/package.json +5 -5
|
@@ -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',
|
|
@@ -852,6 +836,13 @@ export function buildTranscriptFromSessionHistory(history) {
|
|
|
852
836
|
}
|
|
853
837
|
continue;
|
|
854
838
|
}
|
|
839
|
+
if (entry.role === 'user' && entry.kind === 'userInterjection') {
|
|
840
|
+
const text = String(entry.userInput ?? '').trim();
|
|
841
|
+
if (text) {
|
|
842
|
+
entries.push({ body: text, kind: 'user', title: 'You · sent mid-turn' });
|
|
843
|
+
}
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
855
846
|
const text = textFromHistoryEntry(entry);
|
|
856
847
|
if ((entry.role === 'model' || entry.role === 'assistant') && text) {
|
|
857
848
|
if (isTurnFailureMarker(text)) {
|
|
@@ -988,7 +979,7 @@ function createInitialShellState(session, serverModels, debugUi) {
|
|
|
988
979
|
activeTurnInputPreformatted: false,
|
|
989
980
|
agentMode: session.agentMode,
|
|
990
981
|
analyzingImages: 0,
|
|
991
|
-
approvalCursor:
|
|
982
|
+
approvalCursor: 0,
|
|
992
983
|
approvalPrompt: null,
|
|
993
984
|
approvalScrollOffset: 0,
|
|
994
985
|
autoYes: session.autoYes,
|
|
@@ -1022,6 +1013,7 @@ function createInitialShellState(session, serverModels, debugUi) {
|
|
|
1022
1013
|
resumePickerSessions: [],
|
|
1023
1014
|
transcriptScrollOffset: 0,
|
|
1024
1015
|
queuedMessage: null,
|
|
1016
|
+
turnMessages: [],
|
|
1025
1017
|
serverModels: serverModels.models,
|
|
1026
1018
|
sudoPrompt: null,
|
|
1027
1019
|
status: 'Ready',
|
|
@@ -1271,26 +1263,13 @@ export function navigatePromptHistory(state, direction) {
|
|
|
1271
1263
|
promptHistoryCursor: nextCursor,
|
|
1272
1264
|
};
|
|
1273
1265
|
}
|
|
1274
|
-
function getDefaultApprovalCursor() {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
export function getApprovalChoiceForCursor(cursor) {
|
|
1282
|
-
return (APPROVAL_OPTIONS[Math.min(Math.max(cursor, 0), APPROVAL_OPTIONS.length - 1)]
|
|
1283
|
-
?.value ?? 'n');
|
|
1284
|
-
}
|
|
1285
|
-
export function resolveApprovalChoiceFromInput(input) {
|
|
1286
|
-
const normalizedInput = String(input ?? '').trim().toLowerCase();
|
|
1287
|
-
if (normalizedInput === 'y')
|
|
1288
|
-
return 'y';
|
|
1289
|
-
if (normalizedInput === 'a')
|
|
1290
|
-
return 'a';
|
|
1291
|
-
if (normalizedInput === 'n')
|
|
1292
|
-
return 'n';
|
|
1293
|
-
return null;
|
|
1266
|
+
export function getDefaultApprovalCursor(optionCount) {
|
|
1267
|
+
return Math.max(0, optionCount - 1);
|
|
1268
|
+
}
|
|
1269
|
+
export function getNextApprovalCursor(currentIndex, direction, optionCount) {
|
|
1270
|
+
if (optionCount <= 0)
|
|
1271
|
+
return 0;
|
|
1272
|
+
return (currentIndex + direction + optionCount) % optionCount;
|
|
1294
1273
|
}
|
|
1295
1274
|
export function pauseBusyClock(state, nowMs) {
|
|
1296
1275
|
if (state.busySince === null || state.busyPausedAt !== null)
|
|
@@ -1374,7 +1353,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1374
1353
|
let currentServerModels = serverModels;
|
|
1375
1354
|
let sessionAutoYes = session.autoYes;
|
|
1376
1355
|
let resolveDone = null;
|
|
1377
|
-
let
|
|
1356
|
+
let resolvePermissionDecision = null;
|
|
1378
1357
|
let resolveSudoPassword = null;
|
|
1379
1358
|
let pendingUserInput = null;
|
|
1380
1359
|
let cleanupSudoPasswordPrompt = null;
|
|
@@ -1393,6 +1372,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1393
1372
|
let latestUsageSummary = null;
|
|
1394
1373
|
let pendingTurnEntries = [];
|
|
1395
1374
|
let activeTurnAbort = null;
|
|
1375
|
+
let activeServerTurnId = null;
|
|
1376
|
+
let unacknowledged = new Map();
|
|
1377
|
+
const blockedRows = new WeakMap();
|
|
1396
1378
|
let newConversationInFlight = false;
|
|
1397
1379
|
let todosTouchedThisTurn = false;
|
|
1398
1380
|
const syncTodosState = () => {
|
|
@@ -1597,14 +1579,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1597
1579
|
}
|
|
1598
1580
|
};
|
|
1599
1581
|
const dismissPendingApproval = () => {
|
|
1600
|
-
if (!
|
|
1582
|
+
if (!resolvePermissionDecision)
|
|
1601
1583
|
return;
|
|
1602
|
-
const pendingResolve =
|
|
1603
|
-
|
|
1604
|
-
pendingResolve('
|
|
1584
|
+
const pendingResolve = resolvePermissionDecision;
|
|
1585
|
+
resolvePermissionDecision = null;
|
|
1586
|
+
pendingResolve({ kind: 'deny' });
|
|
1605
1587
|
store.update((current) => ({
|
|
1606
1588
|
...resumeBusyClock(current, Date.now()),
|
|
1607
|
-
approvalCursor:
|
|
1589
|
+
approvalCursor: 0,
|
|
1608
1590
|
approvalPrompt: null,
|
|
1609
1591
|
approvalScrollOffset: 0,
|
|
1610
1592
|
}));
|
|
@@ -1677,6 +1659,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1677
1659
|
status: 'Ready',
|
|
1678
1660
|
thinkingTitle: '',
|
|
1679
1661
|
thinkingNotes: [],
|
|
1662
|
+
turnMessages: [],
|
|
1680
1663
|
workingTools: [],
|
|
1681
1664
|
tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
|
|
1682
1665
|
}));
|
|
@@ -1927,40 +1910,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1927
1910
|
pending.resolve(result);
|
|
1928
1911
|
scheduleLiveFrameRemount();
|
|
1929
1912
|
};
|
|
1930
|
-
const openApprovalPrompt = (
|
|
1913
|
+
const openApprovalPrompt = (request) => new Promise((resolve) => {
|
|
1914
|
+
const deny = { kind: 'deny' };
|
|
1931
1915
|
if (exiting) {
|
|
1932
|
-
resolve(
|
|
1916
|
+
resolve(deny);
|
|
1933
1917
|
return;
|
|
1934
1918
|
}
|
|
1935
|
-
|
|
1919
|
+
resolvePermissionDecision = resolve;
|
|
1936
1920
|
store.update((current) => ({
|
|
1937
1921
|
...pauseBusyClock(current, Date.now()),
|
|
1938
|
-
approvalCursor: getDefaultApprovalCursor(),
|
|
1922
|
+
approvalCursor: getDefaultApprovalCursor(request.options.length),
|
|
1923
|
+
approvalOpenedAt: Date.now(),
|
|
1939
1924
|
approvalScrollOffset: 0,
|
|
1940
1925
|
approvalPrompt: {
|
|
1941
|
-
title,
|
|
1942
|
-
body,
|
|
1943
|
-
diffPreview:
|
|
1944
|
-
? parseDiffPreview(
|
|
1926
|
+
title: request.title,
|
|
1927
|
+
body: request.body,
|
|
1928
|
+
diffPreview: request.diff && request.filePath
|
|
1929
|
+
? parseDiffPreview(request.diff)
|
|
1945
1930
|
: undefined,
|
|
1946
|
-
filePath:
|
|
1931
|
+
filePath: request.filePath,
|
|
1932
|
+
options: request.options,
|
|
1947
1933
|
returnStatus: current.status,
|
|
1948
1934
|
},
|
|
1949
|
-
status: title,
|
|
1935
|
+
status: request.title,
|
|
1950
1936
|
}));
|
|
1951
1937
|
});
|
|
1952
|
-
const handleInlineApprovalChoice = async (
|
|
1938
|
+
const handleInlineApprovalChoice = async (index) => {
|
|
1953
1939
|
const current = store.getState();
|
|
1954
|
-
const
|
|
1955
|
-
|
|
1940
|
+
const options = current.approvalPrompt?.options ?? [];
|
|
1941
|
+
const chosen = options[index]?.decision ?? { kind: 'deny' };
|
|
1942
|
+
const pendingResolve = resolvePermissionDecision;
|
|
1943
|
+
resolvePermissionDecision = null;
|
|
1956
1944
|
store.update((next) => ({
|
|
1957
1945
|
...resumeBusyClock(next, Date.now()),
|
|
1958
|
-
approvalCursor: getDefaultApprovalCursor(),
|
|
1946
|
+
approvalCursor: getDefaultApprovalCursor(options.length),
|
|
1959
1947
|
approvalPrompt: null,
|
|
1960
1948
|
approvalScrollOffset: 0,
|
|
1961
1949
|
status: current.approvalPrompt?.returnStatus ?? next.status,
|
|
1962
1950
|
}));
|
|
1963
|
-
pendingResolve?.(
|
|
1951
|
+
pendingResolve?.(chosen);
|
|
1964
1952
|
};
|
|
1965
1953
|
const refreshServerModels = async () => {
|
|
1966
1954
|
currentServerModels = await models.fetchServerModels({ config: authConfig });
|
|
@@ -2230,6 +2218,129 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2230
2218
|
}));
|
|
2231
2219
|
await handleSubmit(queued.body);
|
|
2232
2220
|
};
|
|
2221
|
+
let turnMessageCounter = 0;
|
|
2222
|
+
const upsertTurnMessage = (id, patch) => {
|
|
2223
|
+
store.update((current) => ({
|
|
2224
|
+
...current,
|
|
2225
|
+
turnMessages: current.turnMessages.map((message) => message.id === id ? { ...message, ...patch } : message),
|
|
2226
|
+
}));
|
|
2227
|
+
};
|
|
2228
|
+
const recoverUnacknowledgedMessages = (autoSubmits = true) => {
|
|
2229
|
+
if (unacknowledged.size === 0)
|
|
2230
|
+
return;
|
|
2231
|
+
const stranded = [...unacknowledged.values()];
|
|
2232
|
+
unacknowledged = new Map();
|
|
2233
|
+
for (const { row } of stranded) {
|
|
2234
|
+
upsertTurnMessage(row, {
|
|
2235
|
+
state: 'queued',
|
|
2236
|
+
note: autoSubmits
|
|
2237
|
+
? 'not read — sending as the next prompt'
|
|
2238
|
+
: 'not read — left in the composer',
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
const existing = store.getState().queuedMessage;
|
|
2242
|
+
const combined = [
|
|
2243
|
+
...stranded.map((entry) => entry.queued),
|
|
2244
|
+
...(existing ? [existing] : []),
|
|
2245
|
+
];
|
|
2246
|
+
const body = combined
|
|
2247
|
+
.map((entry) => entry.body)
|
|
2248
|
+
.filter((entry) => entry.trim())
|
|
2249
|
+
.join('\n\n');
|
|
2250
|
+
if (!body.trim())
|
|
2251
|
+
return;
|
|
2252
|
+
scheduleLiveFrameRemount();
|
|
2253
|
+
store.update((current) => ({
|
|
2254
|
+
...current,
|
|
2255
|
+
queuedMessage: {
|
|
2256
|
+
body,
|
|
2257
|
+
imageAttachments: combined.flatMap((entry) => entry.imageAttachments),
|
|
2258
|
+
pastedChunks: combined.flatMap((entry) => entry.pastedChunks),
|
|
2259
|
+
},
|
|
2260
|
+
}));
|
|
2261
|
+
};
|
|
2262
|
+
const fireQueuedMessage = async () => {
|
|
2263
|
+
const state = store.getState();
|
|
2264
|
+
const queued = state.queuedMessage;
|
|
2265
|
+
if (!queued || !state.busy || exiting)
|
|
2266
|
+
return;
|
|
2267
|
+
const text = (queued.pastedChunks.length
|
|
2268
|
+
? expandPastedChunks(queued.body, queued.pastedChunks)
|
|
2269
|
+
: queued.body).trim();
|
|
2270
|
+
if (!text)
|
|
2271
|
+
return;
|
|
2272
|
+
const turnId = activeServerTurnId;
|
|
2273
|
+
const blockedReason = queued.imageAttachments.length > 0
|
|
2274
|
+
? 'waiting — images go with the next prompt'
|
|
2275
|
+
: !turnId
|
|
2276
|
+
? 'waiting for the turn to end'
|
|
2277
|
+
: null;
|
|
2278
|
+
const existingRow = blockedRows.get(queued);
|
|
2279
|
+
if (existingRow !== undefined) {
|
|
2280
|
+
upsertTurnMessage(existingRow, {
|
|
2281
|
+
state: 'queued',
|
|
2282
|
+
...(blockedReason ? { note: blockedReason } : {}),
|
|
2283
|
+
});
|
|
2284
|
+
if (blockedReason || !turnId)
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
const id = existingRow ?? ++turnMessageCounter;
|
|
2288
|
+
const messageId = `m${id}_${Date.now().toString(36)}`;
|
|
2289
|
+
if (existingRow === undefined) {
|
|
2290
|
+
scheduleLiveFrameRemount();
|
|
2291
|
+
store.update((current) => ({
|
|
2292
|
+
...current,
|
|
2293
|
+
turnMessages: [
|
|
2294
|
+
...current.turnMessages,
|
|
2295
|
+
{
|
|
2296
|
+
id,
|
|
2297
|
+
text,
|
|
2298
|
+
state: blockedReason ? 'queued' : 'sending',
|
|
2299
|
+
...(blockedReason ? { note: blockedReason } : {}),
|
|
2300
|
+
},
|
|
2301
|
+
],
|
|
2302
|
+
}));
|
|
2303
|
+
}
|
|
2304
|
+
if (blockedReason || !turnId) {
|
|
2305
|
+
blockedRows.set(queued, id);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
blockedRows.delete(queued);
|
|
2309
|
+
unacknowledged.set(messageId, { row: id, queued });
|
|
2310
|
+
scheduleLiveFrameRemount();
|
|
2311
|
+
store.update((current) => current.queuedMessage === queued
|
|
2312
|
+
? { ...current, queuedMessage: null }
|
|
2313
|
+
: current);
|
|
2314
|
+
queueTurnEntry({
|
|
2315
|
+
body: text,
|
|
2316
|
+
kind: 'user',
|
|
2317
|
+
preformatted: queued.pastedChunks.length > 0,
|
|
2318
|
+
title: 'You · sent mid-turn',
|
|
2319
|
+
});
|
|
2320
|
+
const rollback = (note) => {
|
|
2321
|
+
if (!unacknowledged.delete(messageId))
|
|
2322
|
+
return;
|
|
2323
|
+
upsertTurnMessage(id, { state: 'queued', note });
|
|
2324
|
+
scheduleLiveFrameRemount();
|
|
2325
|
+
store.update((current) => current.queuedMessage ? current : { ...current, queuedMessage: queued });
|
|
2326
|
+
};
|
|
2327
|
+
let outcome;
|
|
2328
|
+
try {
|
|
2329
|
+
outcome = await chat.postInterjection({
|
|
2330
|
+
config: authConfig,
|
|
2331
|
+
turnId,
|
|
2332
|
+
text,
|
|
2333
|
+
messageId,
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
catch (error) {
|
|
2337
|
+
rollback(`not sent — ${error?.message ?? 'request failed'}`);
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
if (outcome === 'stale') {
|
|
2341
|
+
rollback('turn ended — sending as the next prompt');
|
|
2342
|
+
}
|
|
2343
|
+
};
|
|
2233
2344
|
const handleSubmit = async (rawInput) => {
|
|
2234
2345
|
const chunks = store.getState().pastedChunks;
|
|
2235
2346
|
const expanded = chunks.length
|
|
@@ -2481,6 +2592,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2481
2592
|
}
|
|
2482
2593
|
latestUsageSummary = null;
|
|
2483
2594
|
disarmExitConfirm();
|
|
2595
|
+
unacknowledged = new Map();
|
|
2484
2596
|
const turnStartedAt = Date.now();
|
|
2485
2597
|
const turnGeneration = ++activeTurnGeneration;
|
|
2486
2598
|
const turnAbort = new AbortController();
|
|
@@ -2513,6 +2625,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2513
2625
|
transcriptScrollOffset: 0,
|
|
2514
2626
|
tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
|
|
2515
2627
|
turnCounter: current.turnCounter + 1,
|
|
2628
|
+
turnMessages: [],
|
|
2516
2629
|
workingTools: [],
|
|
2517
2630
|
}));
|
|
2518
2631
|
try {
|
|
@@ -2523,6 +2636,25 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2523
2636
|
input,
|
|
2524
2637
|
imageAttachments,
|
|
2525
2638
|
signal: turnAbort.signal,
|
|
2639
|
+
onTurnStart: (serverTurnId) => {
|
|
2640
|
+
if (turnGeneration !== activeTurnGeneration)
|
|
2641
|
+
return;
|
|
2642
|
+
activeServerTurnId = serverTurnId;
|
|
2643
|
+
},
|
|
2644
|
+
onInterjectionDelivered: (event) => {
|
|
2645
|
+
if (turnGeneration !== activeTurnGeneration)
|
|
2646
|
+
return;
|
|
2647
|
+
for (const messageId of event.messageIds ?? []) {
|
|
2648
|
+
const pending = unacknowledged.get(messageId);
|
|
2649
|
+
if (!pending)
|
|
2650
|
+
continue;
|
|
2651
|
+
unacknowledged.delete(messageId);
|
|
2652
|
+
upsertTurnMessage(pending.row, {
|
|
2653
|
+
state: 'delivered',
|
|
2654
|
+
note: undefined,
|
|
2655
|
+
});
|
|
2656
|
+
}
|
|
2657
|
+
},
|
|
2526
2658
|
});
|
|
2527
2659
|
if (turnGeneration !== activeTurnGeneration)
|
|
2528
2660
|
return;
|
|
@@ -2571,6 +2703,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2571
2703
|
status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
|
|
2572
2704
|
tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
|
|
2573
2705
|
}));
|
|
2706
|
+
recoverUnacknowledgedMessages();
|
|
2574
2707
|
await flushQueuedMessage();
|
|
2575
2708
|
}
|
|
2576
2709
|
catch (error) {
|
|
@@ -2616,6 +2749,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2616
2749
|
appendError(error.message);
|
|
2617
2750
|
}
|
|
2618
2751
|
await remountTui();
|
|
2752
|
+
recoverUnacknowledgedMessages(!cancelled);
|
|
2619
2753
|
if (!cancelled && turnGeneration === activeTurnGeneration) {
|
|
2620
2754
|
await flushQueuedMessage();
|
|
2621
2755
|
}
|
|
@@ -2626,7 +2760,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2626
2760
|
}
|
|
2627
2761
|
if (turnGeneration === activeTurnGeneration) {
|
|
2628
2762
|
lastTurnStartedAt = null;
|
|
2763
|
+
activeServerTurnId = null;
|
|
2629
2764
|
}
|
|
2765
|
+
recoverUnacknowledgedMessages();
|
|
2630
2766
|
}
|
|
2631
2767
|
};
|
|
2632
2768
|
session.onImageAnalysis = (activeImageCount) => {
|
|
@@ -2721,44 +2857,28 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2721
2857
|
projectIndex.onContextLog = session.onContextLog;
|
|
2722
2858
|
session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
|
|
2723
2859
|
session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
|
|
2724
|
-
session.
|
|
2860
|
+
session.requestPermission = async (request) => {
|
|
2861
|
+
const deny = { kind: 'deny' };
|
|
2725
2862
|
if (exiting)
|
|
2726
|
-
return
|
|
2863
|
+
return deny;
|
|
2727
2864
|
if (sessionAutoYes)
|
|
2728
|
-
return
|
|
2729
|
-
const
|
|
2730
|
-
if (
|
|
2731
|
-
setAgentMode('auto-accept');
|
|
2732
|
-
syncShellStateFromSession();
|
|
2865
|
+
return { kind: 'once' };
|
|
2866
|
+
const decision = await openApprovalPrompt(request);
|
|
2867
|
+
if (decision.kind === 'always-bucket') {
|
|
2733
2868
|
appendTurnAwareEntry({
|
|
2734
|
-
body:
|
|
2869
|
+
body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
|
|
2735
2870
|
kind: 'system',
|
|
2736
2871
|
title: 'Approvals',
|
|
2737
2872
|
});
|
|
2738
|
-
return true;
|
|
2739
2873
|
}
|
|
2740
|
-
|
|
2741
|
-
};
|
|
2742
|
-
session.confirmPatch = async (filePath, patch) => {
|
|
2743
|
-
if (exiting)
|
|
2744
|
-
return false;
|
|
2745
|
-
if (sessionAutoYes)
|
|
2746
|
-
return true;
|
|
2747
|
-
const choice = await openApprovalPrompt('Approve patch?', 'Review changes before applying.', {
|
|
2748
|
-
diff: patch,
|
|
2749
|
-
filePath,
|
|
2750
|
-
});
|
|
2751
|
-
if (choice === 'a') {
|
|
2752
|
-
setAgentMode('auto-accept');
|
|
2753
|
-
syncShellStateFromSession();
|
|
2874
|
+
else if (decision.kind === 'always-prefix') {
|
|
2754
2875
|
appendTurnAwareEntry({
|
|
2755
|
-
body:
|
|
2876
|
+
body: `Allowed for the rest of this session: commands starting with \`${decision.prefix}\`.`,
|
|
2756
2877
|
kind: 'system',
|
|
2757
2878
|
title: 'Approvals',
|
|
2758
2879
|
});
|
|
2759
|
-
return true;
|
|
2760
2880
|
}
|
|
2761
|
-
return
|
|
2881
|
+
return decision;
|
|
2762
2882
|
};
|
|
2763
2883
|
const shellInputHandlers = {
|
|
2764
2884
|
getApprovalScrollLimit: () => {
|
|
@@ -2791,6 +2911,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2791
2911
|
onJobsPickerKill: handleJobsPickerKill,
|
|
2792
2912
|
onSudoPasswordInput: handleSudoPasswordInput,
|
|
2793
2913
|
onSubmit: handleSubmit,
|
|
2914
|
+
onFireQueuedMessage: fireQueuedMessage,
|
|
2794
2915
|
};
|
|
2795
2916
|
const unsubscribe = store.subscribe(() => {
|
|
2796
2917
|
renderCurrentFrame();
|