@superjackfruitlabs/salaam 0.0.4 → 0.0.5
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/CHANGELOG.md +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/dist/src/tui.js
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
1
|
-
import { Editor,
|
|
1
|
+
import { Editor, ProcessTerminal, TuiAltScreen, ScrollView, VStack, HStack, matchesKey, } from '@earendil-works/pi-tui';
|
|
2
|
+
import { extractMentions, isSensitivePath } from './mentions.js';
|
|
3
|
+
import { MentionAutocompleteProvider } from './tui-mentions.js';
|
|
2
4
|
import { execFile, spawn } from 'node:child_process';
|
|
3
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { cancelChoices, interactiveCoding, queuedItems, runningAttempt, turnKey, turnKeyAction, } from './tui-turn-keys.js';
|
|
4
7
|
import { randomUUID } from 'node:crypto';
|
|
5
8
|
import { join, resolve, basename } from 'node:path';
|
|
6
9
|
import { existsSync, mkdtempSync, writeFileSync, openSync, readSync, closeSync, fstatSync, constants, rmSync, } from 'node:fs';
|
|
7
10
|
import { tmpdir } from 'node:os';
|
|
8
11
|
import { ROOT } from './config.js';
|
|
9
12
|
import { PI_PROVIDERS, REASONING_LEVELS, providerRoute, } from './provider-config.js';
|
|
10
|
-
import { SLASH_COMMANDS, SLASH_COMPLETIONS, parseSlashCommand, slashHelp } from './tui-commands.js';
|
|
13
|
+
import { INIT_INSTRUCTION, SLASH_COMMANDS, SLASH_COMPLETIONS, parseSlashCommand, slashHelp, } from './tui-commands.js';
|
|
14
|
+
import { branchInstruction, commitInstruction, currentBranch, defaultBase, forgeTool, loadGitPrefs, prInstruction, validBranchName, } from './git-helpers.js';
|
|
11
15
|
import { conversationExport, writeConversationExport } from './tui-export.js';
|
|
16
|
+
import { describeHooks } from './hooks.js';
|
|
17
|
+
import { expandCustomCommand, loadCustomCommands, matchCustomCommand, } from './custom-commands.js';
|
|
12
18
|
import { credentialPath } from './credentials.js';
|
|
13
19
|
import { call } from './transport.js';
|
|
20
|
+
import { notificationFor, notify, notifyMode, jobsText } from './tui-notify.js';
|
|
14
21
|
import { OperatorClient } from './tui-client.js';
|
|
15
22
|
import { SalaamTheme, safeText, shortId } from './tui-theme.js';
|
|
16
|
-
import { Dynamic, DocumentView, header, tabs, status, sidebar, } from './tui-view.js';
|
|
17
|
-
import {
|
|
23
|
+
import { Dynamic, DocumentView, header, modelLabel, tabs, status, sidebar, } from './tui-view.js';
|
|
24
|
+
import { nextMode } from './autonomy.js';
|
|
25
|
+
import { ChoiceModal, FormModal, ConfirmModal, ApprovalModal, approvalReason, onceOnlyReason, TextModal, } from './tui-modals.js';
|
|
18
26
|
import { commandTerminal } from './coding.js';
|
|
27
|
+
import { rewindItems, rewindSummary } from './tui-review.js';
|
|
28
|
+
import { classifierItems, classifierNotice } from './tui-classifier.js';
|
|
29
|
+
import { GLOBAL_KEYS, REVIEW_KEYS, keySheet } from './tui-keymap.js';
|
|
30
|
+
import { HerdrReporter, statusEnabled, terminalState, titleFor } from './terminal-status.js';
|
|
31
|
+
import { ComposerHistory, ctrlCAction, historyPath, searchHistory, searchPrompt, shellPassthrough, } from './tui-composer.js';
|
|
19
32
|
/** Frames are atomic. A slow terminal gets one fresh full redraw after drain. */
|
|
20
33
|
export class SalaamTerminal extends ProcessTerminal {
|
|
21
34
|
waiting = false;
|
|
@@ -115,6 +128,12 @@ export class SalaamApp {
|
|
|
115
128
|
lastSession;
|
|
116
129
|
lastRevision;
|
|
117
130
|
historyKeys = new Set();
|
|
131
|
+
/** Messages this user sent, kept across sessions without secret-looking lines (Ctrl+R searches it). */
|
|
132
|
+
sent = new ComposerHistory(historyPath());
|
|
133
|
+
/** When Ctrl+C was last pressed on an empty composer; a second press within 2 s leaves. */
|
|
134
|
+
ctrlCArmed;
|
|
135
|
+
/** Reverse history search in progress: the query, the matching entry and the draft it replaces. */
|
|
136
|
+
search;
|
|
118
137
|
removeInput;
|
|
119
138
|
constructor(options) {
|
|
120
139
|
this.options = options;
|
|
@@ -141,7 +160,9 @@ export class SalaamApp {
|
|
|
141
160
|
}, { paddingX: 2 });
|
|
142
161
|
// The app journals the exact expanded input before clearing it. The library's submit path trims and clears first.
|
|
143
162
|
this.editor.disableSubmit = true;
|
|
144
|
-
|
|
163
|
+
for (const entry of this.sent.entries)
|
|
164
|
+
this.editor.addToHistory(entry);
|
|
165
|
+
this.editor.setAutocompleteProvider(new MentionAutocompleteProvider([...SLASH_COMPLETIONS], options.cwd ?? process.cwd()));
|
|
145
166
|
this.editor.onChange = () => {
|
|
146
167
|
const text = this.editor.getExpandedText(), clean = safeText(text);
|
|
147
168
|
if (clean !== text) {
|
|
@@ -150,6 +171,9 @@ export class SalaamApp {
|
|
|
150
171
|
}
|
|
151
172
|
if (this.client.snapshot)
|
|
152
173
|
this.drafts.set(this.client.snapshot.id, clean);
|
|
174
|
+
// Starting a command reloads this workspace's custom commands into the completion list.
|
|
175
|
+
if (clean === '/')
|
|
176
|
+
this.refreshCommandCompletions();
|
|
153
177
|
this.tui.requestRender();
|
|
154
178
|
};
|
|
155
179
|
this.document = new DocumentView(this.view);
|
|
@@ -199,12 +223,37 @@ export class SalaamApp {
|
|
|
199
223
|
this.done = new Promise((resolve) => (this.finish = resolve));
|
|
200
224
|
this.removeInput = this.tui.addInputListener((data) => this.input(data));
|
|
201
225
|
this.client.on('change', () => this.changed());
|
|
226
|
+
this.client.on('durable', (event) => {
|
|
227
|
+
const message = notificationFor(event);
|
|
228
|
+
if (message)
|
|
229
|
+
notify(message, notifyMode());
|
|
230
|
+
});
|
|
202
231
|
}
|
|
203
232
|
contentWidth() {
|
|
204
233
|
return Math.max(1, this.terminal.columns - (this.rail && this.terminal.columns >= 104 ? 31 : 0));
|
|
205
234
|
}
|
|
235
|
+
// Trial polish C: the window title and Herdr say working / needs you / idle.
|
|
236
|
+
statusOn = statusEnabled();
|
|
237
|
+
herdr = new HerdrReporter();
|
|
238
|
+
lastTitle;
|
|
239
|
+
reportStatus() {
|
|
240
|
+
if (!this.statusOn)
|
|
241
|
+
return;
|
|
242
|
+
const snapshot = this.client.snapshot;
|
|
243
|
+
const state = terminalState(snapshot);
|
|
244
|
+
const name = snapshot
|
|
245
|
+
? (this.client.names[snapshot.id] ?? (snapshot.cwd ? basename(snapshot.cwd) : undefined))
|
|
246
|
+
: undefined;
|
|
247
|
+
const title = titleFor(state, name);
|
|
248
|
+
if (title !== this.lastTitle) {
|
|
249
|
+
this.lastTitle = title;
|
|
250
|
+
this.terminal.setTitle(title);
|
|
251
|
+
}
|
|
252
|
+
this.herdr.report(state, snapshot?.id);
|
|
253
|
+
}
|
|
206
254
|
changed() {
|
|
207
255
|
const snapshot = this.client.snapshot;
|
|
256
|
+
this.reportStatus();
|
|
208
257
|
if (snapshot) {
|
|
209
258
|
if (snapshot.id !== this.lastSession) {
|
|
210
259
|
if (this.lastSession)
|
|
@@ -224,6 +273,11 @@ export class SalaamApp {
|
|
|
224
273
|
this.historyKeys.add(submission.id);
|
|
225
274
|
this.editor.addToHistory(submission.text);
|
|
226
275
|
}
|
|
276
|
+
// Phase 2b: the status line shows this session's mode; reload it when the session changes.
|
|
277
|
+
if (this.client.snapshot?.id !== this.modeSession) {
|
|
278
|
+
this.modeSession = this.client.snapshot?.id;
|
|
279
|
+
void this.loadMode().catch(() => { });
|
|
280
|
+
}
|
|
227
281
|
if (this.client.workspace?.revision !== this.lastRevision) {
|
|
228
282
|
this.lastRevision = this.client.workspace?.revision;
|
|
229
283
|
this.view.diff = undefined;
|
|
@@ -236,16 +290,70 @@ export class SalaamApp {
|
|
|
236
290
|
this.tui.requestRender();
|
|
237
291
|
}
|
|
238
292
|
input(data) {
|
|
239
|
-
if (
|
|
240
|
-
|
|
293
|
+
if (this.search)
|
|
294
|
+
return this.searchKey(data);
|
|
295
|
+
if (matchesKey(data, 'ctrl+c')) {
|
|
296
|
+
this.ctrlC();
|
|
297
|
+
return { consume: true };
|
|
298
|
+
}
|
|
299
|
+
this.ctrlCArmed = undefined;
|
|
300
|
+
if (matchesKey(data, 'ctrl+d') && !this.editor.getExpandedText()) {
|
|
241
301
|
this.finish();
|
|
242
302
|
return { consume: true };
|
|
243
303
|
}
|
|
244
304
|
if (this.modal)
|
|
245
305
|
return;
|
|
306
|
+
if (matchesKey(data, 'ctrl+r')) {
|
|
307
|
+
this.startSearch();
|
|
308
|
+
return { consume: true };
|
|
309
|
+
}
|
|
246
310
|
if (this.editor.isShowingAutocomplete() &&
|
|
247
311
|
(matchesKey(data, 'enter') || matchesKey(data, 'tab') || matchesKey(data, 'escape')))
|
|
248
312
|
return;
|
|
313
|
+
if (data === '?' && this.editor.getExpandedText() === '') {
|
|
314
|
+
this.help();
|
|
315
|
+
return { consume: true };
|
|
316
|
+
}
|
|
317
|
+
// Esc stops the running turn, Enter steers it, Tab queues behind it (interactive coding only).
|
|
318
|
+
const key = turnKey(data);
|
|
319
|
+
const action = key
|
|
320
|
+
? turnKeyAction({
|
|
321
|
+
key,
|
|
322
|
+
text: this.editor.getExpandedText(),
|
|
323
|
+
snapshot: this.client.snapshot,
|
|
324
|
+
modal: false,
|
|
325
|
+
autocomplete: false,
|
|
326
|
+
})
|
|
327
|
+
: null;
|
|
328
|
+
if (action === 'interrupt') {
|
|
329
|
+
const attempt = runningAttempt(this.client.snapshot);
|
|
330
|
+
if (attempt)
|
|
331
|
+
void this.execute(async () => {
|
|
332
|
+
await this.client.cancel(attempt);
|
|
333
|
+
this.client.report('Stopped the turn. Edits already made stay; /undo reverts the last one. Your typed text is kept.');
|
|
334
|
+
});
|
|
335
|
+
return { consume: true };
|
|
336
|
+
}
|
|
337
|
+
if (action === 'steer' || action === 'queue') {
|
|
338
|
+
const text = this.editor.getExpandedText(), session = this.client.snapshot?.id;
|
|
339
|
+
void this.execute(async () => {
|
|
340
|
+
if (action === 'steer')
|
|
341
|
+
await this.client.steer(text);
|
|
342
|
+
else {
|
|
343
|
+
await this.client.submit(text);
|
|
344
|
+
const waiting = this.client.snapshot ? queuedItems(this.client.snapshot).length : 0;
|
|
345
|
+
this.client.report('Queued for after this turn (' + waiting + ' waiting). /queue edits or removes them.');
|
|
346
|
+
}
|
|
347
|
+
this.editor.addToHistory(text);
|
|
348
|
+
this.sent.add(text);
|
|
349
|
+
if (this.client.snapshot?.id === session && this.editor.getExpandedText() === text) {
|
|
350
|
+
this.editor.setText('');
|
|
351
|
+
if (session)
|
|
352
|
+
this.drafts.delete(session);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
return { consume: true };
|
|
356
|
+
}
|
|
249
357
|
if (matchesKey(data, 'enter')) {
|
|
250
358
|
const text = this.editor.getExpandedText(), session = this.client.snapshot?.id;
|
|
251
359
|
const submit = () => {
|
|
@@ -260,24 +368,12 @@ export class SalaamApp {
|
|
|
260
368
|
submit();
|
|
261
369
|
return { consume: true };
|
|
262
370
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
['ctrl+t', () => this.setTab('commands')],
|
|
270
|
-
[
|
|
271
|
-
'ctrl+b',
|
|
272
|
-
() => {
|
|
273
|
-
this.rail = !this.rail;
|
|
274
|
-
this.tui.requestRender(true);
|
|
275
|
-
},
|
|
276
|
-
],
|
|
277
|
-
['ctrl+r', () => void this.execute(() => this.refresh())],
|
|
278
|
-
['ctrl+e', () => void this.execute(() => this.editPrompt())],
|
|
279
|
-
['f1', () => this.help()],
|
|
280
|
-
];
|
|
371
|
+
// Ctrl+K kills to the end of the line while the composer has text (readline); on an empty
|
|
372
|
+
// composer it opens the actions palette. F2 always opens it.
|
|
373
|
+
if (matchesKey(data, 'ctrl+k') && this.editor.getExpandedText() !== '')
|
|
374
|
+
return;
|
|
375
|
+
const actions = this.globalActions();
|
|
376
|
+
const keys = GLOBAL_KEYS.map((k) => [k.key, () => actions[k.action]()]);
|
|
281
377
|
for (const [key, action] of keys)
|
|
282
378
|
if (matchesKey(data, key)) {
|
|
283
379
|
if (this.view.busy)
|
|
@@ -293,6 +389,11 @@ export class SalaamApp {
|
|
|
293
389
|
this.setTab(names[(names.indexOf(this.view.tab) + 1) % names.length]);
|
|
294
390
|
return { consume: true };
|
|
295
391
|
}
|
|
392
|
+
if (this.view.tab === 'changes' && interactiveCoding(this.client.snapshot)) {
|
|
393
|
+
const handled = this.reviewKey(data);
|
|
394
|
+
if (handled)
|
|
395
|
+
return handled;
|
|
396
|
+
}
|
|
296
397
|
if (this.view.tab === 'changes' &&
|
|
297
398
|
(matchesKey(data, 'alt+right') || matchesKey(data, 'alt+left'))) {
|
|
298
399
|
const offset = matchesKey(data, 'alt+left')
|
|
@@ -304,6 +405,30 @@ export class SalaamApp {
|
|
|
304
405
|
}
|
|
305
406
|
return;
|
|
306
407
|
}
|
|
408
|
+
/** What each key in GLOBAL_KEYS does; the table owns the keys, this owns the actions. */
|
|
409
|
+
globalActions() {
|
|
410
|
+
return (this.actionTable ??= {
|
|
411
|
+
palette: () => this.palette(),
|
|
412
|
+
sessions: () => void this.execute(() => this.sessions()),
|
|
413
|
+
new: () => void this.execute(() => this.newSession()),
|
|
414
|
+
approvals: () => this.approvals(),
|
|
415
|
+
changes: () => this.setTab('changes'),
|
|
416
|
+
commands: () => this.setTab('commands'),
|
|
417
|
+
rail: () => {
|
|
418
|
+
this.rail = !this.rail;
|
|
419
|
+
this.tui.requestRender(true);
|
|
420
|
+
},
|
|
421
|
+
transcript: () => {
|
|
422
|
+
this.view.transcript = !this.view.transcript;
|
|
423
|
+
this.setTab('conversation');
|
|
424
|
+
},
|
|
425
|
+
refresh: () => void this.execute(() => this.refresh()),
|
|
426
|
+
editor: () => void this.execute(() => this.editPrompt()),
|
|
427
|
+
mode: () => void this.execute(() => this.cycleMode()),
|
|
428
|
+
help: () => this.help(),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
actionTable;
|
|
307
432
|
async execute(work) {
|
|
308
433
|
if (this.view.busy) {
|
|
309
434
|
const session = this.client.snapshot?.id ?? this.options.session;
|
|
@@ -351,7 +476,7 @@ export class SalaamApp {
|
|
|
351
476
|
choose(item.value);
|
|
352
477
|
}, () => this.terminal.rows));
|
|
353
478
|
}
|
|
354
|
-
confirm(title, description, label, confirm) {
|
|
479
|
+
confirm(title, description, label, confirm, confirmByDefault = false) {
|
|
355
480
|
if (this.terminal.rows < 18) {
|
|
356
481
|
this.client.report('Expand the terminal to at least 18 rows to review this action.');
|
|
357
482
|
return;
|
|
@@ -359,7 +484,7 @@ export class SalaamApp {
|
|
|
359
484
|
this.showModal(new ConfirmModal(title, description, label, this.view.theme, () => this.tui.requestRender(), () => this.closeModal(), () => {
|
|
360
485
|
this.closeModal();
|
|
361
486
|
void this.execute(confirm);
|
|
362
|
-
}));
|
|
487
|
+
}, confirmByDefault));
|
|
363
488
|
}
|
|
364
489
|
async startService() {
|
|
365
490
|
await this.client.list();
|
|
@@ -386,22 +511,50 @@ export class SalaamApp {
|
|
|
386
511
|
if (this.client.connection === 'offline')
|
|
387
512
|
throw new Error('Service did not become available. Inspect sal service status.');
|
|
388
513
|
}
|
|
514
|
+
/** Interactive sessions switch in place between turns; otherwise the choice opens a new session. */
|
|
515
|
+
async applyRoute(route) {
|
|
516
|
+
if (this.client.snapshot?.provider_route && this.client.controlling) {
|
|
517
|
+
try {
|
|
518
|
+
await this.client.switchRoute(route);
|
|
519
|
+
this.client.notice = 'Now using ' + modelLabel(this.client) + ' in this session.';
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
// A route that limits output differently cannot join this session. Keep the choice and
|
|
524
|
+
// open the new session with it, rather than leaving the old model selected.
|
|
525
|
+
if (error.code !== 'provider_output_cap_changed')
|
|
526
|
+
throw error;
|
|
527
|
+
this.selectedRoute = route;
|
|
528
|
+
this.client.notice = `${route.model ?? route.provider} limits output differently from this session's model, so it starts in a new session. This session stays as it is.`;
|
|
529
|
+
await this.newSession();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
this.selectedRoute = route;
|
|
534
|
+
await this.newSession();
|
|
535
|
+
}
|
|
389
536
|
modelPicker() {
|
|
390
|
-
|
|
537
|
+
const configured = (this.client.snapshot?.provider_route ?? this.client.health?.provider_route)
|
|
538
|
+
?.provider;
|
|
539
|
+
const live = PI_PROVIDERS.filter((p) => p.getModels().length);
|
|
540
|
+
this.choice(this.client.snapshot ? 'Provider · switches this session' : 'Provider', [
|
|
541
|
+
// The configured provider first; the scripted demo last, never the default.
|
|
542
|
+
...[
|
|
543
|
+
...live.filter((p) => p.id === configured),
|
|
544
|
+
...live.filter((p) => p.id !== configured),
|
|
545
|
+
].map((p) => ({
|
|
546
|
+
value: p.id,
|
|
547
|
+
label: p.name,
|
|
548
|
+
description: p.id + (p.id === configured ? ' · current' : ''),
|
|
549
|
+
})),
|
|
391
550
|
{
|
|
392
551
|
value: 'offline',
|
|
393
552
|
label: 'Offline demo',
|
|
394
553
|
description: 'Scripted fixture, not an AI model',
|
|
395
554
|
},
|
|
396
|
-
...PI_PROVIDERS.filter((p) => p.getModels().length).map((p) => ({
|
|
397
|
-
value: p.id,
|
|
398
|
-
label: p.name,
|
|
399
|
-
description: p.id,
|
|
400
|
-
})),
|
|
401
555
|
], (provider) => {
|
|
402
556
|
if (provider === 'offline') {
|
|
403
|
-
this.
|
|
404
|
-
void this.execute(() => this.newSession());
|
|
557
|
+
void this.execute(() => this.applyRoute(providerRoute()));
|
|
405
558
|
return;
|
|
406
559
|
}
|
|
407
560
|
const catalog = PI_PROVIDERS.find((p) => p.id === provider);
|
|
@@ -409,10 +562,87 @@ export class SalaamApp {
|
|
|
409
562
|
value: m.id,
|
|
410
563
|
label: m.name,
|
|
411
564
|
description: m.id + ' · ' + m.contextWindow + ' context tokens',
|
|
412
|
-
})), (model) =>
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
565
|
+
})), (model) => void this.execute(() => this.applyRoute(providerRoute(provider, model))));
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
modeSession;
|
|
569
|
+
/** Phase 2b: the session's mode and full access, for the status line. */
|
|
570
|
+
async loadMode() {
|
|
571
|
+
if (!this.client.snapshot) {
|
|
572
|
+
this.view.mode = undefined;
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const current = await this.client.permissions();
|
|
576
|
+
this.view.mode =
|
|
577
|
+
current.configurable && current.coding
|
|
578
|
+
? {
|
|
579
|
+
mode: current.policy.mode,
|
|
580
|
+
...(current.policy.access ? { access: current.policy.access } : {}),
|
|
581
|
+
...(current.command_sandbox ? { sandbox: current.command_sandbox } : {}),
|
|
582
|
+
}
|
|
583
|
+
: undefined;
|
|
584
|
+
this.tui.requestRender();
|
|
585
|
+
}
|
|
586
|
+
/** Shift+Tab: review → accept edits → auto. Full access and YOLO are kept as they are; each has
|
|
587
|
+
* its own command. */
|
|
588
|
+
async cycleMode() {
|
|
589
|
+
const session = this.client.snapshot?.id;
|
|
590
|
+
if (!session)
|
|
591
|
+
throw new Error('Open a session first.');
|
|
592
|
+
const current = await this.client.permissions();
|
|
593
|
+
if (!current.configurable || !current.coding)
|
|
594
|
+
throw new Error('Modes are for local coding sessions. No session or files were changed.');
|
|
595
|
+
await this.client.setPermissions(session, current.policy.revision, {
|
|
596
|
+
mode: nextMode(current.policy.mode),
|
|
597
|
+
paths: [],
|
|
598
|
+
...(current.policy.access ? { access: current.policy.access } : {}),
|
|
599
|
+
});
|
|
600
|
+
await this.loadMode();
|
|
601
|
+
}
|
|
602
|
+
async setAccess(access) {
|
|
603
|
+
const session = this.client.snapshot.id;
|
|
604
|
+
const current = await this.client.permissions();
|
|
605
|
+
await this.client.setPermissions(session, current.policy.revision, {
|
|
606
|
+
mode: current.policy.mode,
|
|
607
|
+
paths: current.policy.paths,
|
|
608
|
+
...(access ? { access } : {}),
|
|
609
|
+
});
|
|
610
|
+
await this.loadMode();
|
|
611
|
+
}
|
|
612
|
+
/** Full access on (with a warning and a confirm) or off (at once). From YOLO it steps down at once. */
|
|
613
|
+
async fullAccess() {
|
|
614
|
+
const session = this.client.snapshot?.id;
|
|
615
|
+
if (!session)
|
|
616
|
+
throw new Error('Open a session first.');
|
|
617
|
+
const current = await this.client.permissions();
|
|
618
|
+
if (!current.configurable || !current.coding)
|
|
619
|
+
throw new Error('Full access is for local coding sessions. Nothing was changed.');
|
|
620
|
+
if (current.policy.access === 'full')
|
|
621
|
+
return this.setAccess(undefined);
|
|
622
|
+
if (current.full_access_allowed === false)
|
|
623
|
+
throw new Error('The operator does not allow full access on this host. Commands stay in the OS sandbox.');
|
|
624
|
+
if (current.policy.access === 'yolo')
|
|
625
|
+
return this.setAccess('full');
|
|
626
|
+
this.confirm('Turn on full access?', 'Later commands in this session run WITHOUT the OS sandbox: they can read and change any file your user can, and reach the network directly. They run without asking, except commands the danger check calls dangerous (force pushes, deletes, publishing, credentials, anything it cannot read), which still ask and say why. Run /full-access again to turn it off.', 'turn on full access', () => this.setAccess('full'));
|
|
627
|
+
}
|
|
628
|
+
/** YOLO on only after the person types "yolo"; off at once. Never in the Shift+Tab cycle. */
|
|
629
|
+
async yolo() {
|
|
630
|
+
const session = this.client.snapshot?.id;
|
|
631
|
+
if (!session)
|
|
632
|
+
throw new Error('Open a session first.');
|
|
633
|
+
const current = await this.client.permissions();
|
|
634
|
+
if (!current.configurable || !current.coding)
|
|
635
|
+
throw new Error('YOLO is for local coding sessions. Nothing was changed.');
|
|
636
|
+
if (current.policy.access === 'yolo')
|
|
637
|
+
return this.setAccess(undefined);
|
|
638
|
+
if (current.yolo_allowed === false)
|
|
639
|
+
throw new Error('YOLO is not allowed for this session: it is only for your own desk sessions on a host whose operator allows it.');
|
|
640
|
+
this.taskInput('Turn on YOLO? Type yolo to confirm', '', 'Type yolo', 'NO approvals at all: every edit and command runs at once, without the OS sandbox, including force pushes, deletes and publishing. Each one is recorded. /yolo again turns it off.', (value) => {
|
|
641
|
+
if (value.trim() !== 'yolo') {
|
|
642
|
+
this.client.report('YOLO stays off: the confirmation was not "yolo".');
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
void this.execute(() => this.setAccess('yolo'));
|
|
416
646
|
});
|
|
417
647
|
}
|
|
418
648
|
showText(title, text) {
|
|
@@ -430,7 +660,39 @@ export class SalaamApp {
|
|
|
430
660
|
return;
|
|
431
661
|
}
|
|
432
662
|
if (command === 'help') {
|
|
433
|
-
|
|
663
|
+
const custom = args ? [] : this.customCommands();
|
|
664
|
+
const problems = args ? [] : this.customCommandProblems;
|
|
665
|
+
this.showText('Salaam commands', slashHelp(args || undefined) +
|
|
666
|
+
(custom.length
|
|
667
|
+
? '\n\nCustom commands (write a prompt only):\n' +
|
|
668
|
+
custom
|
|
669
|
+
.map((c) => `/${c.name}${c.argumentHint ? ' ' + c.argumentHint : ''} — ${c.description} [${c.scope}]`)
|
|
670
|
+
.join('\n')
|
|
671
|
+
: '') +
|
|
672
|
+
(problems.length ? '\n\nSkipped command files:\n' + problems.join('\n') : ''));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (command === 'hooks') {
|
|
676
|
+
if (args === 'revoke') {
|
|
677
|
+
await this.client.revokeHooks();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const { sources } = await this.client.hooks();
|
|
681
|
+
if (args === 'trust') {
|
|
682
|
+
const repo = sources.find((s) => s.scope === 'repo');
|
|
683
|
+
if (!repo?.digest)
|
|
684
|
+
throw new Error('This workspace has no readable .salaam/hooks.json.');
|
|
685
|
+
if (repo.trusted) {
|
|
686
|
+
this.showText('Hooks', 'This workspace’s hooks are already trusted as they are.');
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
this.confirm('Trust this workspace’s hooks?', describeHooks([repo]) +
|
|
690
|
+
'\n\nThese commands will run on your machine, in this session’s sandbox, whenever their event happens. Trust covers this exact content; any change needs trusting again.', 'Trust these hooks', () => this.client.trustHooks(repo.digest));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (args)
|
|
694
|
+
throw new Error('Use /hooks, /hooks trust or /hooks revoke.');
|
|
695
|
+
this.showText('Hooks', describeHooks(sources));
|
|
434
696
|
return;
|
|
435
697
|
}
|
|
436
698
|
if (command === 'keys') {
|
|
@@ -444,9 +706,8 @@ export class SalaamApp {
|
|
|
444
706
|
}
|
|
445
707
|
const slash = args.indexOf('/');
|
|
446
708
|
if (slash <= 0)
|
|
447
|
-
throw new Error('Use /model provider/model.
|
|
448
|
-
this.
|
|
449
|
-
await this.newSession();
|
|
709
|
+
throw new Error('Use /model provider/model.');
|
|
710
|
+
await this.applyRoute(providerRoute(args.slice(0, slash), args.slice(slash + 1)));
|
|
450
711
|
return;
|
|
451
712
|
}
|
|
452
713
|
if (command === 'provider') {
|
|
@@ -455,17 +716,13 @@ export class SalaamApp {
|
|
|
455
716
|
return;
|
|
456
717
|
}
|
|
457
718
|
if (args === 'offline') {
|
|
458
|
-
this.
|
|
459
|
-
await this.newSession();
|
|
719
|
+
await this.applyRoute(providerRoute());
|
|
460
720
|
return;
|
|
461
721
|
}
|
|
462
722
|
const provider = PI_PROVIDERS.find((p) => p.id === args);
|
|
463
723
|
if (!provider)
|
|
464
724
|
throw new Error('Unknown provider. Use /provider to browse the pinned catalog.');
|
|
465
|
-
this.choice('Model · ' + provider.name, provider.getModels().map((m) => ({ value: m.id, label: m.name, description: m.id })), (model) =>
|
|
466
|
-
this.selectedRoute = providerRoute(args, model);
|
|
467
|
-
void this.execute(() => this.newSession());
|
|
468
|
-
});
|
|
725
|
+
this.choice('Model · ' + provider.name, provider.getModels().map((m) => ({ value: m.id, label: m.name, description: m.id })), (model) => void this.execute(() => this.applyRoute(providerRoute(args, model))));
|
|
469
726
|
return;
|
|
470
727
|
}
|
|
471
728
|
if (command === 'new' || command === 'coding') {
|
|
@@ -494,7 +751,7 @@ export class SalaamApp {
|
|
|
494
751
|
this.choice('Settings', [
|
|
495
752
|
{ value: 'permissions', label: 'Permissions · inspect, review or scoped edits' },
|
|
496
753
|
{ value: 'progression', label: 'Progression · manual, bounded tasks or schedules' },
|
|
497
|
-
{ value: 'model', label: 'Provider and model
|
|
754
|
+
{ value: 'model', label: 'Provider and model' },
|
|
498
755
|
{ value: 'theme', label: 'Switch Night / Paper' },
|
|
499
756
|
{ value: 'rail', label: 'Toggle workspace rail' },
|
|
500
757
|
{ value: 'keys', label: 'Keyboard shortcuts' },
|
|
@@ -526,6 +783,10 @@ export class SalaamApp {
|
|
|
526
783
|
}
|
|
527
784
|
if (!snapshot)
|
|
528
785
|
throw new Error('Choose a session with /sessions or /new first.');
|
|
786
|
+
if (command === 'rewind') {
|
|
787
|
+
await this.rewind(args);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
529
790
|
if (command === 'undo') {
|
|
530
791
|
if (args === 'reconcile') {
|
|
531
792
|
await this.client.reconcileUndo();
|
|
@@ -551,6 +812,39 @@ export class SalaamApp {
|
|
|
551
812
|
this.confirm('Fork session', 'Create an independent conversation from the captured history? Both sessions share the same workspace; files are not cloned. Pending approvals and task authority are not copied.', 'Fork conversation', () => this.client.fork());
|
|
552
813
|
return;
|
|
553
814
|
}
|
|
815
|
+
if (command === 'commit' || command === 'branch' || command === 'pr') {
|
|
816
|
+
if (!snapshot.capabilities.includes('local-coding-reviewed'))
|
|
817
|
+
throw new Error(`/${command} runs git. Start a coding session with /coding first.`);
|
|
818
|
+
const root = snapshot.cwd;
|
|
819
|
+
let instruction;
|
|
820
|
+
if (command === 'commit')
|
|
821
|
+
instruction = commitInstruction({ ...loadGitPrefs(), session: snapshot.id });
|
|
822
|
+
else if (command === 'branch') {
|
|
823
|
+
if (!args)
|
|
824
|
+
throw new Error('Usage: /branch <name>');
|
|
825
|
+
instruction = branchInstruction(args);
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
const branch = currentBranch(root), base = args || defaultBase(root);
|
|
829
|
+
if (!branch)
|
|
830
|
+
throw new Error('/pr needs a checked-out branch, not a detached HEAD.');
|
|
831
|
+
if (branch === base)
|
|
832
|
+
throw new Error(`You are on ${base}. Create a branch first with /branch <name>.`);
|
|
833
|
+
if (!validBranchName(base))
|
|
834
|
+
throw new Error(`"${base}" is not a valid branch name.`);
|
|
835
|
+
instruction = prInstruction({ tool: forgeTool(root), branch, base });
|
|
836
|
+
}
|
|
837
|
+
await this.client.submit(instruction);
|
|
838
|
+
this.setTab('conversation');
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (command === 'init') {
|
|
842
|
+
if (!snapshot.capabilities.includes('local-coding-reviewed'))
|
|
843
|
+
throw new Error('/init writes a file. Start a coding session with /coding first.');
|
|
844
|
+
await this.client.submit(INIT_INSTRUCTION);
|
|
845
|
+
this.setTab('conversation');
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
554
848
|
if (command === 'compact') {
|
|
555
849
|
await this.client.compact();
|
|
556
850
|
return;
|
|
@@ -564,7 +858,10 @@ export class SalaamApp {
|
|
|
564
858
|
return;
|
|
565
859
|
}
|
|
566
860
|
if (command === 'diff') {
|
|
567
|
-
|
|
861
|
+
// Coding sessions review their diff file by file in Changes (Phase 3a).
|
|
862
|
+
if (snapshot.capabilities.includes('local-coding-reviewed'))
|
|
863
|
+
this.setTab('changes');
|
|
864
|
+
else if (snapshot.capabilities.includes('local-workspace-read')) {
|
|
568
865
|
const diff = await call(this.client.socket, `/v1/sessions/${snapshot.id}/local-diff`);
|
|
569
866
|
this.showText('Local Git changes', diff.text);
|
|
570
867
|
}
|
|
@@ -580,17 +877,68 @@ export class SalaamApp {
|
|
|
580
877
|
this.taskActions();
|
|
581
878
|
return;
|
|
582
879
|
}
|
|
880
|
+
if (command === 'classifier') {
|
|
881
|
+
const path = `/v1/sessions/${snapshot.id}/danger-classifier`;
|
|
882
|
+
const set = async (choice) => {
|
|
883
|
+
const v = await call(this.client.socket, path, {
|
|
884
|
+
...this.client.actor(),
|
|
885
|
+
choice,
|
|
886
|
+
});
|
|
887
|
+
this.client.notice = classifierNotice(v);
|
|
888
|
+
this.tui.requestRender();
|
|
889
|
+
};
|
|
890
|
+
const want = args.trim();
|
|
891
|
+
if (want === 'on') {
|
|
892
|
+
const v = await call(this.client.socket, path, {
|
|
893
|
+
...this.client.actor(),
|
|
894
|
+
enabled: true,
|
|
895
|
+
});
|
|
896
|
+
this.client.notice = classifierNotice(v);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (want) {
|
|
900
|
+
await set(want);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const v = await call(this.client.socket, path);
|
|
904
|
+
const items = classifierItems(v);
|
|
905
|
+
if (!items.length) {
|
|
906
|
+
this.client.notice = classifierNotice(v);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
this.choice('Command reviewer · ' + classifierNotice(v).split('.')[0], items, (choice) => {
|
|
910
|
+
void this.execute(() => set(choice));
|
|
911
|
+
});
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (command === 'jobs') {
|
|
915
|
+
const base = `/v1/sessions/${snapshot.id}/local-tasks`;
|
|
916
|
+
const stop = /^stop\s+(\S+)$/.exec(args.trim());
|
|
917
|
+
if (stop) {
|
|
918
|
+
await call(this.client.socket, `${base}/${stop[1]}/stop`, {});
|
|
919
|
+
this.client.notice = 'Stopping background command ' + stop[1];
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const { tasks } = await call(this.client.socket, base);
|
|
923
|
+
const outputs = await Promise.all(tasks
|
|
924
|
+
.slice(0, 4)
|
|
925
|
+
.map((t) => call(this.client.socket, `${base}/${t.id}`)));
|
|
926
|
+
this.showText('Background commands', jobsText(tasks, outputs));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
583
929
|
if (command === 'cancel') {
|
|
584
|
-
const active = snapshot
|
|
930
|
+
const active = cancelChoices(snapshot);
|
|
585
931
|
if (!active.length) {
|
|
586
932
|
this.client.report('No active or queued instruction to cancel.');
|
|
587
933
|
return;
|
|
588
934
|
}
|
|
589
|
-
const cancel = (id) => this.confirm('Cancel instruction', 'Cancel attempt ' +
|
|
935
|
+
const cancel = (id) => this.confirm('Cancel instruction', 'Cancel attempt ' +
|
|
936
|
+
shortId(id) +
|
|
937
|
+
'? Your typed text is kept. /exit would leave it running.', 'Cancel work', () => this.client.cancel(id), true);
|
|
590
938
|
if (active.length === 1)
|
|
591
939
|
cancel(active[0].id);
|
|
592
940
|
else
|
|
593
|
-
this.choice('Cancel an instruction', active.map((a) => ({ value: a.id, label: a.
|
|
941
|
+
this.choice('Cancel an instruction', active.map((a) => ({ value: a.id, label: a.label })), cancel);
|
|
594
942
|
return;
|
|
595
943
|
}
|
|
596
944
|
if (command === 'steer') {
|
|
@@ -604,7 +952,22 @@ export class SalaamApp {
|
|
|
604
952
|
await this.client.submit(args);
|
|
605
953
|
return;
|
|
606
954
|
}
|
|
607
|
-
|
|
955
|
+
const queued = queuedItems(snapshot);
|
|
956
|
+
if (!queued.length) {
|
|
957
|
+
this.client.report('Nothing is queued. Tab queues text for after the running turn.');
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
this.choice('Queued instructions', queued.map((q) => ({ value: q.attempt, label: q.text.slice(0, 80) })), (attempt) => {
|
|
961
|
+
const item = queued.find((q) => q.attempt === attempt);
|
|
962
|
+
this.choice('Queued instruction', [
|
|
963
|
+
{ value: 'edit', label: 'Edit', description: 'Move it back into the input' },
|
|
964
|
+
{ value: 'remove', label: 'Remove', description: 'Cancel it before it runs' },
|
|
965
|
+
], (choice) => void this.execute(async () => {
|
|
966
|
+
await this.client.cancel(item.attempt);
|
|
967
|
+
if (choice === 'edit')
|
|
968
|
+
this.editor.setText(item.text);
|
|
969
|
+
}));
|
|
970
|
+
});
|
|
608
971
|
return;
|
|
609
972
|
}
|
|
610
973
|
if (command === 'plan' || command === 'review') {
|
|
@@ -618,6 +981,18 @@ export class SalaamApp {
|
|
|
618
981
|
this.showText('Provider usage', JSON.stringify(this.client.usage ?? { status: 'No usage observations yet; this is not zero usage.' }, null, 2));
|
|
619
982
|
return;
|
|
620
983
|
}
|
|
984
|
+
if (command === 'mode') {
|
|
985
|
+
await this.cycleMode();
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (command === 'full-access') {
|
|
989
|
+
await this.fullAccess();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (command === 'yolo') {
|
|
993
|
+
await this.yolo();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
621
996
|
if (command === 'permissions') {
|
|
622
997
|
await this.permissionControls();
|
|
623
998
|
return;
|
|
@@ -700,14 +1075,11 @@ export class SalaamApp {
|
|
|
700
1075
|
const current = snapshot.provider_route;
|
|
701
1076
|
if (!current || current.provider === 'salaam-proof')
|
|
702
1077
|
throw new Error('Choose a live provider/model first with /model.');
|
|
703
|
-
const choose = (level) =>
|
|
704
|
-
this.selectedRoute = providerRoute(current.provider, current.model, current.max_tokens, current.workspace_id, current.connection, level);
|
|
705
|
-
return this.newSession(snapshot.capabilities.includes('local-coding-reviewed'));
|
|
706
|
-
};
|
|
1078
|
+
const choose = (level) => this.applyRoute(providerRoute(current.provider, current.model, current.max_tokens, current.workspace_id, current.connection, level));
|
|
707
1079
|
if (args)
|
|
708
1080
|
await choose(args);
|
|
709
1081
|
else
|
|
710
|
-
this.choice('Reasoning ·
|
|
1082
|
+
this.choice('Reasoning · current ' + (current.reasoning ?? 'automatic low'), REASONING_LEVELS.map((level) => ({ value: level, label: level })), (level) => void this.execute(() => choose(level)));
|
|
711
1083
|
return;
|
|
712
1084
|
}
|
|
713
1085
|
if (command === 'name') {
|
|
@@ -746,9 +1118,7 @@ export class SalaamApp {
|
|
|
746
1118
|
}
|
|
747
1119
|
async newSession(localCoding = false) {
|
|
748
1120
|
await this.startService();
|
|
749
|
-
this.showModal(new FormModal((localCoding
|
|
750
|
-
? 'New coding session · reviewed edits and unsandboxed commands · '
|
|
751
|
-
: 'New session · ') +
|
|
1121
|
+
this.showModal(new FormModal((localCoding ? 'New coding session · reviewed edits and commands · ' : 'New session · ') +
|
|
752
1122
|
(this.selectedRoute?.model ?? this.client.health?.provider_route.model ?? 'default'), this.options.cwd ?? this.client.snapshot?.cwd ?? process.cwd(), this.view.theme, !localCoding && !!this.client.health?.coding_root, () => this.tui.requestRender(), () => this.closeModal(), (cwd, coding) => {
|
|
753
1123
|
this.closeModal();
|
|
754
1124
|
void this.execute(async () => {
|
|
@@ -758,15 +1128,22 @@ export class SalaamApp {
|
|
|
758
1128
|
}));
|
|
759
1129
|
}
|
|
760
1130
|
async sessions() {
|
|
1131
|
+
const { pickerOrder, sessionDescription } = await import('./session-pick.js');
|
|
761
1132
|
await this.client.list();
|
|
1133
|
+
const now = Date.now();
|
|
762
1134
|
this.choice('Your Salaam sessions', [
|
|
763
1135
|
{ value: 'new', label: '+ New session', description: 'Start a fresh workspace' },
|
|
764
|
-
...this.client.sessions.map((session) => ({
|
|
1136
|
+
...pickerOrder(this.client.sessions, resolve(this.options.cwd ?? process.cwd())).map((session) => ({
|
|
765
1137
|
value: session.id,
|
|
766
1138
|
label: (this.client.names[session.id] ?? basename(session.cwd)) + ' ' + shortId(session.id),
|
|
767
|
-
description: session
|
|
1139
|
+
description: sessionDescription(session, now),
|
|
768
1140
|
})),
|
|
769
1141
|
], (value) => {
|
|
1142
|
+
if (value === 'transcript') {
|
|
1143
|
+
this.view.transcript = !this.view.transcript;
|
|
1144
|
+
this.setTab('conversation');
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
770
1147
|
if (value === 'model')
|
|
771
1148
|
this.modelPicker();
|
|
772
1149
|
else if (value === 'help')
|
|
@@ -785,10 +1162,62 @@ export class SalaamApp {
|
|
|
785
1162
|
});
|
|
786
1163
|
});
|
|
787
1164
|
}
|
|
1165
|
+
commandCache;
|
|
1166
|
+
customCommandProblems = [];
|
|
1167
|
+
/** This workspace's and your own custom commands, reread at most every two seconds. */
|
|
1168
|
+
customCommands() {
|
|
1169
|
+
const root = this.client.snapshot?.cwd ?? this.options.cwd ?? process.cwd();
|
|
1170
|
+
const cached = this.commandCache;
|
|
1171
|
+
if (cached && cached.root === root && Date.now() - cached.at < 2000)
|
|
1172
|
+
return cached.commands;
|
|
1173
|
+
const { commands, problems } = loadCustomCommands(root, this.options.commandsDir);
|
|
1174
|
+
this.customCommandProblems = problems;
|
|
1175
|
+
this.commandCache = { root, at: Date.now(), commands };
|
|
1176
|
+
return commands;
|
|
1177
|
+
}
|
|
1178
|
+
refreshCommandCompletions() {
|
|
1179
|
+
const root = this.client.snapshot?.cwd ?? this.options.cwd ?? process.cwd();
|
|
1180
|
+
this.editor.setAutocompleteProvider(new MentionAutocompleteProvider([
|
|
1181
|
+
...SLASH_COMPLETIONS,
|
|
1182
|
+
...this.customCommands().map((c) => ({
|
|
1183
|
+
name: c.name,
|
|
1184
|
+
description: `${c.description} [${c.scope}]`,
|
|
1185
|
+
})),
|
|
1186
|
+
], root));
|
|
1187
|
+
}
|
|
788
1188
|
async submit() {
|
|
789
1189
|
const text = this.editor.getExpandedText(), session = this.client.snapshot?.id, continuation = this.view.continuation;
|
|
790
1190
|
if (!text.trim())
|
|
791
1191
|
return;
|
|
1192
|
+
const shell = shellPassthrough(text);
|
|
1193
|
+
if (shell) {
|
|
1194
|
+
if (!session)
|
|
1195
|
+
throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
|
|
1196
|
+
if (!interactiveCoding(this.client.snapshot))
|
|
1197
|
+
throw new Error('!command works in local coding sessions (salaam code).');
|
|
1198
|
+
await this.deliver(shell, session, continuation, false, text);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
// A custom command only writes a prompt; built-in names never reach here (they are refused at
|
|
1202
|
+
// load), and the expanded prompt goes through every check an ordinary message does.
|
|
1203
|
+
const custom = text.trim().startsWith('/')
|
|
1204
|
+
? matchCustomCommand(text, this.customCommands())
|
|
1205
|
+
: null;
|
|
1206
|
+
if (custom) {
|
|
1207
|
+
if (!session)
|
|
1208
|
+
throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
|
|
1209
|
+
const prompt = expandCustomCommand(custom.command, custom.args);
|
|
1210
|
+
if (Buffer.byteLength(prompt) > 65536)
|
|
1211
|
+
throw new Error(`/${custom.command.name} expands past 64 KiB. Shorten the command file.`);
|
|
1212
|
+
const secrets = extractMentions(prompt).filter(isSensitivePath);
|
|
1213
|
+
const send = (sensitive) => this.deliver(prompt, session, continuation, sensitive, text);
|
|
1214
|
+
if (secrets.length) {
|
|
1215
|
+
this.confirm('Attach secret-looking files?', `/${custom.command.name} mentions ${secrets.map((s) => '@' + s).join(', ')}, which will be sent to the model in full.`, 'Attach and send', () => send(true));
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
await send(false);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
792
1221
|
const command = parseSlashCommand(text);
|
|
793
1222
|
if (command) {
|
|
794
1223
|
await this.runSlashCommand(command.name, command.args);
|
|
@@ -802,9 +1231,20 @@ export class SalaamApp {
|
|
|
802
1231
|
throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
|
|
803
1232
|
if (Buffer.byteLength(text) > 65536)
|
|
804
1233
|
throw new Error('The composer accepts at most 64 KiB. Use an artifact for longer input.');
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1234
|
+
const secrets = extractMentions(text).filter(isSensitivePath);
|
|
1235
|
+
if (secrets.length) {
|
|
1236
|
+
this.confirm('Attach secret-looking files?', `${secrets.map((s) => '@' + s).join(', ')} will be sent to the model in full.`, 'Attach and send', () => this.deliver(text, session, continuation, true));
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
await this.deliver(text, session, continuation, false);
|
|
1240
|
+
}
|
|
1241
|
+
async deliver(text, session, continuation, confirmSensitive,
|
|
1242
|
+
/** What was typed, when it differs from what is sent (a custom command). */
|
|
1243
|
+
typed = text) {
|
|
1244
|
+
await this.client.submit(text, continuation, confirmSensitive);
|
|
1245
|
+
this.editor.addToHistory(typed);
|
|
1246
|
+
this.sent.add(typed);
|
|
1247
|
+
if (this.client.snapshot?.id === session && this.editor.getExpandedText() === typed) {
|
|
808
1248
|
this.editor.setText('');
|
|
809
1249
|
this.drafts.delete(session);
|
|
810
1250
|
}
|
|
@@ -817,9 +1257,114 @@ export class SalaamApp {
|
|
|
817
1257
|
this.view.tab = tab;
|
|
818
1258
|
this.resetScroll();
|
|
819
1259
|
if (tab === 'changes')
|
|
820
|
-
void this.execute(() => this.
|
|
1260
|
+
void this.execute(() => this.loadChanges());
|
|
821
1261
|
this.tui.requestRender();
|
|
822
1262
|
}
|
|
1263
|
+
/** A local coding session reviews its folder against the review base; a coding workspace
|
|
1264
|
+
* keeps its guarded diff. */
|
|
1265
|
+
loadChanges() {
|
|
1266
|
+
return interactiveCoding(this.client.snapshot)
|
|
1267
|
+
? this.loadReview()
|
|
1268
|
+
: this.loadDiff(this.view.diffOffset);
|
|
1269
|
+
}
|
|
1270
|
+
async loadReview(file = this.view.review?.file ?? 0) {
|
|
1271
|
+
const id = this.client.snapshot?.id;
|
|
1272
|
+
const data = await this.client.changeReview();
|
|
1273
|
+
if (this.client.snapshot?.id !== id)
|
|
1274
|
+
return;
|
|
1275
|
+
const index = data?.files.length ? Math.min(Math.max(0, file), data.files.length - 1) : 0;
|
|
1276
|
+
const path = data?.files[index]?.path;
|
|
1277
|
+
const hunks = data && path ? await this.client.changeHunks(data, path) : undefined;
|
|
1278
|
+
this.view.review = { data, file: index, hunks, hunk: -1 };
|
|
1279
|
+
this.document.invalidate();
|
|
1280
|
+
this.tui.requestRender();
|
|
1281
|
+
}
|
|
1282
|
+
/** Changes-tab keys: alt+left/right file, alt+up/down hunk; with an empty editor, a accepts,
|
|
1283
|
+
* r reverts and i starts a follow-up instruction about the selection. */
|
|
1284
|
+
reviewKey(data) {
|
|
1285
|
+
const review = this.view.review;
|
|
1286
|
+
if (!review?.data?.files.length)
|
|
1287
|
+
return;
|
|
1288
|
+
const file = review.data.files[review.file];
|
|
1289
|
+
if (matchesKey(data, REVIEW_KEYS.nextFile.key) ||
|
|
1290
|
+
matchesKey(data, REVIEW_KEYS.previousFile.key)) {
|
|
1291
|
+
const next = review.file + (matchesKey(data, REVIEW_KEYS.nextFile.key) ? 1 : -1);
|
|
1292
|
+
if (next >= 0 && next < review.data.files.length)
|
|
1293
|
+
void this.execute(() => this.loadReview(next));
|
|
1294
|
+
return { consume: true };
|
|
1295
|
+
}
|
|
1296
|
+
if (matchesKey(data, REVIEW_KEYS.nextHunk.key) ||
|
|
1297
|
+
matchesKey(data, REVIEW_KEYS.previousHunk.key)) {
|
|
1298
|
+
const count = review.hunks?.length ?? 0;
|
|
1299
|
+
review.hunk = Math.max(-1, Math.min(count - 1, review.hunk + (matchesKey(data, REVIEW_KEYS.nextHunk.key) ? 1 : -1)));
|
|
1300
|
+
this.document.invalidate();
|
|
1301
|
+
this.tui.requestRender();
|
|
1302
|
+
return { consume: true };
|
|
1303
|
+
}
|
|
1304
|
+
if (this.editor.getExpandedText() !== '')
|
|
1305
|
+
return;
|
|
1306
|
+
const hunk = review.hunk >= 0 ? review.hunks?.[review.hunk] : undefined;
|
|
1307
|
+
const what = file.path + (hunk ? ` (hunk ${review.hunk + 1})` : '');
|
|
1308
|
+
const act = (action) => async () => {
|
|
1309
|
+
const data = await this.client.changeAct(review.data, action, file.path, hunk);
|
|
1310
|
+
this.view.review = { data, file: review.file, hunk: -1 };
|
|
1311
|
+
await this.loadReview(review.file);
|
|
1312
|
+
this.client.report((action === 'accept' ? 'Accepted ' : 'Reverted ') + what + '.');
|
|
1313
|
+
};
|
|
1314
|
+
if (data === REVIEW_KEYS.accept.key) {
|
|
1315
|
+
void this.execute(act('accept'));
|
|
1316
|
+
return { consume: true };
|
|
1317
|
+
}
|
|
1318
|
+
if (data === REVIEW_KEYS.revert.key) {
|
|
1319
|
+
this.confirm('Revert ' + what, (hunk ? 'Puts this hunk back' : 'Puts this file back') +
|
|
1320
|
+
' the way it was at the review base. Only the reviewed version is written; if the file changed since, nothing is written and you review again.', 'Revert', act('revert'));
|
|
1321
|
+
return { consume: true };
|
|
1322
|
+
}
|
|
1323
|
+
if (data === REVIEW_KEYS.instruct.key) {
|
|
1324
|
+
this.setTab('conversation');
|
|
1325
|
+
this.editor.setText(`In ${file.path}${hunk ? ` (the change at ${hunk.text.split('\n')[0]})` : ''}: `);
|
|
1326
|
+
return { consume: true };
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
/** /rewind [turn]: choose a turn and what to restore, review, then confirm once. */
|
|
1331
|
+
async rewind(args) {
|
|
1332
|
+
const points = await this.client.checkpoints();
|
|
1333
|
+
if (!points.length)
|
|
1334
|
+
throw new Error('No turn has been checkpointed in this session yet. A checkpoint is taken as each turn starts.');
|
|
1335
|
+
const choose = (attempt) => this.choice('Rewind · what to restore', [
|
|
1336
|
+
{
|
|
1337
|
+
value: 'both',
|
|
1338
|
+
label: 'Files and conversation',
|
|
1339
|
+
description: 'Restore files and continue from before this turn in a new session',
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
value: 'files',
|
|
1343
|
+
label: 'Files only',
|
|
1344
|
+
description: 'Restore files; keep the conversation',
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
value: 'conversation',
|
|
1348
|
+
label: 'Conversation only',
|
|
1349
|
+
description: 'Continue from before this turn in a new session; files stay',
|
|
1350
|
+
},
|
|
1351
|
+
], (mode) => void this.execute(async () => {
|
|
1352
|
+
const review = await this.client.rewindReview(attempt, mode);
|
|
1353
|
+
this.confirm(`Rewind to before turn ${review.turn}`, rewindSummary(review), 'Rewind', async () => {
|
|
1354
|
+
const text = await this.client.rewind(review);
|
|
1355
|
+
if (review.mode !== 'files')
|
|
1356
|
+
this.editor.setText(text);
|
|
1357
|
+
});
|
|
1358
|
+
}));
|
|
1359
|
+
if (args) {
|
|
1360
|
+
const found = points.find((p) => String(p.turn) === args.trim());
|
|
1361
|
+
if (!found)
|
|
1362
|
+
throw new Error(`No checkpoint for turn ${args.trim()}. /rewind lists them.`);
|
|
1363
|
+
choose(found.attempt_id);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
this.choice('Rewind to before which turn?', rewindItems(points), choose);
|
|
1367
|
+
}
|
|
823
1368
|
resetScroll() {
|
|
824
1369
|
if (this.view.tab === 'conversation' || this.view.tab === 'commands')
|
|
825
1370
|
this.scroll.scrollToEnd();
|
|
@@ -829,7 +1374,7 @@ export class SalaamApp {
|
|
|
829
1374
|
async refresh() {
|
|
830
1375
|
await this.client.refresh();
|
|
831
1376
|
if (this.view.tab === 'changes')
|
|
832
|
-
await this.
|
|
1377
|
+
await this.loadChanges();
|
|
833
1378
|
}
|
|
834
1379
|
async loadDiff(offset) {
|
|
835
1380
|
if (this.diffBusy)
|
|
@@ -865,7 +1410,12 @@ export class SalaamApp {
|
|
|
865
1410
|
const open = (id) => void this.execute(async () => {
|
|
866
1411
|
const approval = approvals.find((a) => a.id === id);
|
|
867
1412
|
const reviewed = await this.client.review(approval);
|
|
868
|
-
|
|
1413
|
+
// Desk sessions can approve for the session or always; strict work only ever approves once.
|
|
1414
|
+
const rules = await this.client.approvalRules().catch(() => ({ applies: false }));
|
|
1415
|
+
const onceOnly = rules.applies
|
|
1416
|
+
? onceOnlyReason(String(reviewed.operation.value))
|
|
1417
|
+
: undefined;
|
|
1418
|
+
this.showApproval(reviewed.approval, reviewed.operation, rules.applies && !onceOnly, onceOnly, rules.applies);
|
|
869
1419
|
});
|
|
870
1420
|
if (approvals.length === 1)
|
|
871
1421
|
open(approvals[0].id);
|
|
@@ -877,11 +1427,121 @@ export class SalaamApp {
|
|
|
877
1427
|
description: shortId(approval.id) + ' · revision ' + approval.revision,
|
|
878
1428
|
})), open);
|
|
879
1429
|
}
|
|
880
|
-
|
|
1430
|
+
/** /permissions → Approval rules: list them, remove one, or add one in plain words. */
|
|
1431
|
+
async approvalRules() {
|
|
1432
|
+
const { applies, rules, problems } = await this.client.approvalRules();
|
|
1433
|
+
if (!applies) {
|
|
1434
|
+
this.client.report('Approval rules apply only to desk coding sessions.');
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
const describe = (r) => `${r.effect} ${r.tool} ${r.command ?? (r.paths ?? []).join(', ')}`.trim();
|
|
1438
|
+
this.choice('Approval rules · deny beats ask beats allow · unmatched requests ask', [
|
|
1439
|
+
{
|
|
1440
|
+
value: 'add',
|
|
1441
|
+
label: 'Add a rule',
|
|
1442
|
+
description: 'e.g. allow command npm test * · deny edit src/secrets/**',
|
|
1443
|
+
},
|
|
1444
|
+
...rules.map((r) => ({
|
|
1445
|
+
value: 'rule:' + r.id,
|
|
1446
|
+
label: describe(r),
|
|
1447
|
+
description: r.source === 'user'
|
|
1448
|
+
? 'From your permissions.json; edit that file to change it'
|
|
1449
|
+
: r.source === 'repo'
|
|
1450
|
+
? 'Always, in this repo · choose to remove'
|
|
1451
|
+
: 'This session · choose to remove',
|
|
1452
|
+
})),
|
|
1453
|
+
...problems.map((p, i) => ({ value: 'problem:' + i, label: 'Problem', description: p })),
|
|
1454
|
+
], (value) => {
|
|
1455
|
+
if (value === 'add') {
|
|
1456
|
+
this.taskInput('New approval rule', '', 'effect tool words-or-paths', 'allow|ask|deny, then command or edit, then command words (a final * takes any further words) or comma-separated path globs. Example: allow command git status', (text) => {
|
|
1457
|
+
const [effect, tool, ...rest] = text.trim().split(/\s+/);
|
|
1458
|
+
const target = rest.join(' ');
|
|
1459
|
+
const rule = tool === 'edit'
|
|
1460
|
+
? {
|
|
1461
|
+
effect,
|
|
1462
|
+
tool,
|
|
1463
|
+
paths: target
|
|
1464
|
+
.split(',')
|
|
1465
|
+
.map((p) => p.trim())
|
|
1466
|
+
.filter(Boolean),
|
|
1467
|
+
}
|
|
1468
|
+
: { effect, tool, command: target };
|
|
1469
|
+
this.choice('Keep this rule for…', [
|
|
1470
|
+
{
|
|
1471
|
+
value: 'session',
|
|
1472
|
+
label: 'This session',
|
|
1473
|
+
description: 'Gone when the session ends',
|
|
1474
|
+
},
|
|
1475
|
+
{
|
|
1476
|
+
value: 'repo',
|
|
1477
|
+
label: 'This repo',
|
|
1478
|
+
description: "Every desk session here; kept in Salaam's state",
|
|
1479
|
+
},
|
|
1480
|
+
], (scope) => void this.execute(() => this.client.addRule(scope, rule)));
|
|
1481
|
+
});
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (!value.startsWith('rule:'))
|
|
1485
|
+
return;
|
|
1486
|
+
const rule = rules.find((r) => 'rule:' + r.id === value);
|
|
1487
|
+
if (rule.source === 'user') {
|
|
1488
|
+
this.client.report('This rule comes from your permissions.json; edit that file to change it.');
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
this.confirm('Remove this rule?', describe(rule), 'remove', () => this.client.removeRule(rule.id));
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
showApproval(approval, operation, offerRules = false, onceOnly, desk = false) {
|
|
881
1495
|
this.showModal(new ApprovalModal(approval, operation, this.view.theme, () => this.terminal.rows, () => this.client.snapshot?.approvals.find((a) => a.id === approval.id), () => this.tui.requestRender(), () => this.closeModal(), (decision) => {
|
|
882
1496
|
this.closeModal();
|
|
883
|
-
|
|
884
|
-
|
|
1497
|
+
if (decision === 'approve' || decision === 'deny') {
|
|
1498
|
+
void this.execute(() => this.client.decide(approval, decision));
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (decision === 'deny-feedback') {
|
|
1502
|
+
// Deny, then tell the agent why in its own turn, the way steering reaches it.
|
|
1503
|
+
this.taskInput('Deny and tell it why', '', 'Why not, or what to do instead', 'One line. The request is denied and this reaches the agent as feedback.', (why) => void this.execute(async () => {
|
|
1504
|
+
await this.client.decide(approval, 'deny');
|
|
1505
|
+
const text = why.trim();
|
|
1506
|
+
if (!text)
|
|
1507
|
+
return;
|
|
1508
|
+
// The turn has usually moved on by now; if it ended, put the reason in the
|
|
1509
|
+
// composer instead of losing it.
|
|
1510
|
+
const feedback = `I denied that request (${safeText(operation.target)}). ${text}`;
|
|
1511
|
+
try {
|
|
1512
|
+
await this.client.steer(feedback);
|
|
1513
|
+
}
|
|
1514
|
+
catch {
|
|
1515
|
+
this.editor.setText(feedback);
|
|
1516
|
+
this.client.report('Denied. The turn had ended, so your reason is in the composer; press enter to send it.');
|
|
1517
|
+
}
|
|
1518
|
+
}));
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
// The rule is the exact request just reviewed: its command words or its edited paths.
|
|
1522
|
+
// If it cannot become a rule (shell features, odd words), nothing is approved; say why.
|
|
1523
|
+
const approveWith = (scope) => async () => {
|
|
1524
|
+
// While the approval is pending the status line shows it, not a notice, so a
|
|
1525
|
+
// refused rule is shown where it cannot be missed.
|
|
1526
|
+
try {
|
|
1527
|
+
await this.client.ruleFromOperation(operation.id, scope);
|
|
1528
|
+
}
|
|
1529
|
+
catch (error) {
|
|
1530
|
+
this.showText('Not saved as a rule', `${error instanceof Error ? error.message : String(error)}\n\nNothing was approved. Press ctrl+o to approve it once instead.`);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
await this.client.decide(approval, 'approve');
|
|
1534
|
+
this.client.report(scope === 'session'
|
|
1535
|
+
? 'Approved, and the same request will be approved for the rest of this session.'
|
|
1536
|
+
: 'Approved, and the same request will always be approved in this repo. Remove it in /permissions.');
|
|
1537
|
+
};
|
|
1538
|
+
if (decision === 'approve-session')
|
|
1539
|
+
void this.execute(approveWith('session'));
|
|
1540
|
+
else
|
|
1541
|
+
this.confirm('Always allow in this repo?', `${safeText(operation.target)}
|
|
1542
|
+
|
|
1543
|
+
This exact request will be approved without asking in every desk session on this repository, until you remove it in /permissions. It is kept in Salaam's state, not in the repository.`, 'always allow', approveWith('repo'));
|
|
1544
|
+
}, offerRules, onceOnly, { desk, reason: approvalReason(String(operation.value), this.view.mode?.mode) }), 86);
|
|
885
1545
|
}
|
|
886
1546
|
taskInput(title, initial, field, hint, submit) {
|
|
887
1547
|
this.showModal(new FormModal(title, initial, this.view.theme, false, () => this.tui.requestRender(), () => this.closeModal(), (value) => {
|
|
@@ -909,10 +1569,15 @@ export class SalaamApp {
|
|
|
909
1569
|
},
|
|
910
1570
|
...(current.coding
|
|
911
1571
|
? [
|
|
1572
|
+
{
|
|
1573
|
+
value: 'rules',
|
|
1574
|
+
label: 'Approval rules',
|
|
1575
|
+
description: 'Allow, ask or deny by command words and path globs',
|
|
1576
|
+
},
|
|
912
1577
|
{
|
|
913
1578
|
value: 'review',
|
|
914
1579
|
label: 'Review every mutation',
|
|
915
|
-
description: 'Explicit approval for each edit and
|
|
1580
|
+
description: 'Explicit approval for each edit and shell command',
|
|
916
1581
|
},
|
|
917
1582
|
{
|
|
918
1583
|
value: 'scoped-edit',
|
|
@@ -922,11 +1587,15 @@ export class SalaamApp {
|
|
|
922
1587
|
]
|
|
923
1588
|
: []),
|
|
924
1589
|
], (mode) => {
|
|
1590
|
+
if (mode === 'rules') {
|
|
1591
|
+
void this.execute(() => this.approvalRules());
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
925
1594
|
if (mode === 'inspect-current') {
|
|
926
1595
|
this.showText('Permissions and execution boundary', JSON.stringify(current, null, 2));
|
|
927
1596
|
return;
|
|
928
1597
|
}
|
|
929
|
-
const apply = (paths) => this.showModal(new TextModal('Review permission policy', `Session ${session}\nWorkspace ${current.policy.root}\nMode ${mode}\nAuto-edit paths: ${paths.join(', ') || '(none)'}\
|
|
1598
|
+
const apply = (paths) => this.showModal(new TextModal('Review permission policy', `Session ${session}\nWorkspace ${current.policy.root}\nMode ${mode}\nAuto-edit paths: ${paths.join(', ') || '(none)'}\nCommands ask unless an approval rule or the session mode allows them (auto allows only sandboxed commands; full access allows all but dangerous ones; YOLO allows everything). Commands run in the host OS sandbox unless full access or YOLO is on. Progression and budgets remain unchanged. Already-running commands are not stopped.`, this.view.theme, () => this.terminal.rows, () => this.tui.requestRender(), () => this.closeModal(), undefined, () => {
|
|
930
1599
|
this.closeModal();
|
|
931
1600
|
void this.execute(() => this.client.setPermissions(session, current.policy.revision, {
|
|
932
1601
|
mode: mode,
|
|
@@ -1311,7 +1980,11 @@ export class SalaamApp {
|
|
|
1311
1980
|
label: 'Operator inbox',
|
|
1312
1981
|
description: 'Shared durable attention and results',
|
|
1313
1982
|
},
|
|
1314
|
-
{
|
|
1983
|
+
{
|
|
1984
|
+
value: 'model',
|
|
1985
|
+
label: 'Choose model and provider',
|
|
1986
|
+
description: '/model · switches this session',
|
|
1987
|
+
},
|
|
1315
1988
|
{ value: 'help', label: 'Slash commands', description: '/help' },
|
|
1316
1989
|
{ value: 'new', label: 'New session', description: 'ctrl+n' },
|
|
1317
1990
|
{
|
|
@@ -1331,8 +2004,13 @@ export class SalaamApp {
|
|
|
1331
2004
|
label: 'Job actions',
|
|
1332
2005
|
description: 'Bound one-shot admission and durable occurrence recovery',
|
|
1333
2006
|
},
|
|
1334
|
-
{ value: 'changes', label: 'Review changes', description: '
|
|
2007
|
+
{ value: 'changes', label: 'Review changes', description: 'alt+g · guarded workspace diff' },
|
|
1335
2008
|
{ value: 'commands', label: 'Commands and output', description: 'ctrl+t' },
|
|
2009
|
+
{
|
|
2010
|
+
value: 'transcript',
|
|
2011
|
+
label: 'Full transcript',
|
|
2012
|
+
description: 'ctrl+x · every tool call and its output',
|
|
2013
|
+
},
|
|
1336
2014
|
{
|
|
1337
2015
|
value: 'context',
|
|
1338
2016
|
label: 'Session context',
|
|
@@ -1384,11 +2062,11 @@ export class SalaamApp {
|
|
|
1384
2062
|
label: 'Cancel current work',
|
|
1385
2063
|
description: 'Explicitly cancel a running instruction',
|
|
1386
2064
|
},
|
|
1387
|
-
{ value: 'refresh', label: 'Refresh connection', description: '
|
|
2065
|
+
{ value: 'refresh', label: 'Refresh connection', description: 'f5' },
|
|
1388
2066
|
{
|
|
1389
2067
|
value: 'editor',
|
|
1390
2068
|
label: 'Edit prompt externally',
|
|
1391
|
-
description: 'ctrl+
|
|
2069
|
+
description: 'ctrl+g · VISUAL or EDITOR',
|
|
1392
2070
|
},
|
|
1393
2071
|
{
|
|
1394
2072
|
value: 'theme',
|
|
@@ -1591,44 +2269,16 @@ export class SalaamApp {
|
|
|
1591
2269
|
this.showModal(new TextModal('Tool · ' + p.tool, `Attempt: ${p.attempt_id}\nTool call: ${p.tool_call_id}\nOutcome: ${outcome ? (outcome.payload.failed ? 'failed' : 'finished') : 'running or not captured'}\nInput${p.input_truncated ? ' (truncated; full digest recorded)' : ''}:\n${p.input ?? '(not captured)'}`, this.view.theme, () => this.terminal.rows, () => this.tui.requestRender(), () => this.closeModal()), 100);
|
|
1592
2270
|
});
|
|
1593
2271
|
}
|
|
2272
|
+
/** The keys for where you are: `?` on an empty composer, or F1. Built from tui-keymap.ts. */
|
|
1594
2273
|
help() {
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
label: 'Alt+Enter / Ctrl+J · new line',
|
|
1604
|
-
description: 'Shift+Enter also works with supported keyboard protocols',
|
|
1605
|
-
},
|
|
1606
|
-
{
|
|
1607
|
-
value: 'close',
|
|
1608
|
-
label: 'Ctrl+K · all actions',
|
|
1609
|
-
description: 'Ctrl+P sessions · Ctrl+N new session · Ctrl+O approvals',
|
|
1610
|
-
},
|
|
1611
|
-
{
|
|
1612
|
-
value: 'close',
|
|
1613
|
-
label: 'Tab · switch view',
|
|
1614
|
-
description: 'Conversation · Changes · Commands · Context',
|
|
1615
|
-
},
|
|
1616
|
-
{
|
|
1617
|
-
value: 'close',
|
|
1618
|
-
label: 'PageUp / PageDown · scroll',
|
|
1619
|
-
description: 'Mouse wheel also scrolls; Ctrl+Home/End jump to the top/bottom',
|
|
1620
|
-
},
|
|
1621
|
-
{
|
|
1622
|
-
value: 'close',
|
|
1623
|
-
label: 'Ctrl+E · external prompt editor',
|
|
1624
|
-
description: 'Runs VISUAL or EDITOR with terminal modes restored',
|
|
1625
|
-
},
|
|
1626
|
-
{
|
|
1627
|
-
value: 'close',
|
|
1628
|
-
label: 'Ctrl+C · detach',
|
|
1629
|
-
description: 'Work continues. Cancel and stop service are separate actions.',
|
|
1630
|
-
},
|
|
1631
|
-
], () => { });
|
|
2274
|
+
const snapshot = this.client.snapshot;
|
|
2275
|
+
const sheet = keySheet({
|
|
2276
|
+
tab: this.view.tab,
|
|
2277
|
+
coding: interactiveCoding(snapshot),
|
|
2278
|
+
running: interactiveCoding(snapshot) && runningAttempt(snapshot) !== undefined,
|
|
2279
|
+
custom: this.customCommands(),
|
|
2280
|
+
});
|
|
2281
|
+
this.choice(sheet.title, sheet.rows.map((row) => ({ value: 'close', ...row })), () => { });
|
|
1632
2282
|
}
|
|
1633
2283
|
async editPrompt() {
|
|
1634
2284
|
const [command, ...args] = editorArguments(process.env.VISUAL ?? process.env.EDITOR ?? 'vi');
|
|
@@ -1668,8 +2318,88 @@ export class SalaamApp {
|
|
|
1668
2318
|
}
|
|
1669
2319
|
}
|
|
1670
2320
|
}
|
|
2321
|
+
/** Ctrl+C: stop a running coding turn, else clear the composer, else arm; twice within 2 s leaves. */
|
|
2322
|
+
ctrlC() {
|
|
2323
|
+
const attempt = interactiveCoding(this.client.snapshot)
|
|
2324
|
+
? runningAttempt(this.client.snapshot)
|
|
2325
|
+
: undefined;
|
|
2326
|
+
const action = this.modal
|
|
2327
|
+
? 'exit'
|
|
2328
|
+
: ctrlCAction({
|
|
2329
|
+
running: attempt !== undefined,
|
|
2330
|
+
text: this.editor.getExpandedText(),
|
|
2331
|
+
armedAt: this.ctrlCArmed,
|
|
2332
|
+
now: Date.now(),
|
|
2333
|
+
});
|
|
2334
|
+
this.ctrlCArmed = undefined;
|
|
2335
|
+
if (action === 'exit')
|
|
2336
|
+
this.finish();
|
|
2337
|
+
else if (action === 'interrupt')
|
|
2338
|
+
void this.execute(async () => {
|
|
2339
|
+
await this.client.cancel(attempt);
|
|
2340
|
+
this.client.report('Stopped the turn. Press ctrl+c twice to leave.');
|
|
2341
|
+
});
|
|
2342
|
+
else if (action === 'clear') {
|
|
2343
|
+
this.editor.setText('');
|
|
2344
|
+
this.client.report('Cleared. Press ctrl+c again to leave, or ctrl+- to undo.');
|
|
2345
|
+
}
|
|
2346
|
+
else {
|
|
2347
|
+
this.ctrlCArmed = Date.now();
|
|
2348
|
+
this.client.report('Press ctrl+c again to leave. Work continues in the service.');
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
startSearch() {
|
|
2352
|
+
this.search = { query: '', index: this.sent.entries.length, draft: this.editor.getText() };
|
|
2353
|
+
this.client.report(searchPrompt('', undefined));
|
|
2354
|
+
}
|
|
2355
|
+
/** Keys while Ctrl+R search is open: type to narrow, ctrl+r for older, enter keeps, esc restores. */
|
|
2356
|
+
searchKey(data) {
|
|
2357
|
+
const s = this.search, entries = this.sent.entries;
|
|
2358
|
+
const show = () => {
|
|
2359
|
+
const match = s.index < entries.length ? entries[s.index] : undefined;
|
|
2360
|
+
if (match !== undefined)
|
|
2361
|
+
this.editor.setText(match);
|
|
2362
|
+
this.client.report(searchPrompt(s.query, match));
|
|
2363
|
+
};
|
|
2364
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+g')) {
|
|
2365
|
+
this.editor.setText(s.draft);
|
|
2366
|
+
this.search = undefined;
|
|
2367
|
+
this.client.report('');
|
|
2368
|
+
}
|
|
2369
|
+
else if (matchesKey(data, 'enter') || matchesKey(data, 'right') || matchesKey(data, 'left')) {
|
|
2370
|
+
this.search = undefined;
|
|
2371
|
+
this.client.report('');
|
|
2372
|
+
}
|
|
2373
|
+
else if (matchesKey(data, 'ctrl+r')) {
|
|
2374
|
+
const found = searchHistory(entries, s.query, s.index);
|
|
2375
|
+
if (found >= 0)
|
|
2376
|
+
s.index = found;
|
|
2377
|
+
show();
|
|
2378
|
+
}
|
|
2379
|
+
else if (matchesKey(data, 'backspace')) {
|
|
2380
|
+
s.query = s.query.slice(0, -1);
|
|
2381
|
+
s.index = searchHistory(entries, s.query, entries.length);
|
|
2382
|
+
if (s.index < 0)
|
|
2383
|
+
s.index = entries.length;
|
|
2384
|
+
if (s.query === '')
|
|
2385
|
+
this.editor.setText(s.draft);
|
|
2386
|
+
show();
|
|
2387
|
+
}
|
|
2388
|
+
else if (data.length === 1 && data >= ' ' && data !== '\x7f') {
|
|
2389
|
+
s.query += data;
|
|
2390
|
+
const found = searchHistory(entries, s.query, Math.min(s.index + 1, entries.length));
|
|
2391
|
+
s.index = found >= 0 ? found : entries.length;
|
|
2392
|
+
if (found < 0)
|
|
2393
|
+
this.editor.setText(s.draft);
|
|
2394
|
+
show();
|
|
2395
|
+
}
|
|
2396
|
+
this.tui.requestRender();
|
|
2397
|
+
return { consume: true };
|
|
2398
|
+
}
|
|
1671
2399
|
async initialize() {
|
|
1672
2400
|
await this.execute(async () => {
|
|
2401
|
+
if (this.options.start)
|
|
2402
|
+
return this.openHere(this.options.start);
|
|
1673
2403
|
await this.client.list();
|
|
1674
2404
|
if (this.options.session)
|
|
1675
2405
|
await this.client.open(this.options.session, {
|
|
@@ -1684,6 +2414,29 @@ export class SalaamApp {
|
|
|
1684
2414
|
}
|
|
1685
2415
|
});
|
|
1686
2416
|
}
|
|
2417
|
+
/** One command for this folder: start the service if needed, then continue the folder's
|
|
2418
|
+
* latest session, start a new coding session in it, or show the session list. */
|
|
2419
|
+
async openHere(start) {
|
|
2420
|
+
const { latestSessionFor, deskUsesAnthropic } = await import('./session-pick.js');
|
|
2421
|
+
await this.startService();
|
|
2422
|
+
if (deskUsesAnthropic(!!this.selectedRoute, this.client.health?.provider_route.provider, existsSync(credentialPath()) || existsSync(credentialPath() + '.json')))
|
|
2423
|
+
this.selectedRoute = providerRoute('anthropic');
|
|
2424
|
+
if (start === 'resume')
|
|
2425
|
+
return this.sessions();
|
|
2426
|
+
const cwd = resolve(this.options.cwd ?? process.cwd());
|
|
2427
|
+
const existing = start === 'continue' ? latestSessionFor(this.client.sessions, cwd) : undefined;
|
|
2428
|
+
if (existing)
|
|
2429
|
+
await this.client.open(existing, {
|
|
2430
|
+
observe: this.options.observe,
|
|
2431
|
+
takeover: this.options.takeover,
|
|
2432
|
+
});
|
|
2433
|
+
else {
|
|
2434
|
+
await this.client.create(cwd, false, this.selectedRoute, true);
|
|
2435
|
+
if (start === 'continue')
|
|
2436
|
+
this.client.notice = 'No earlier session in this folder, so this is a new one.';
|
|
2437
|
+
}
|
|
2438
|
+
this.setTab('conversation');
|
|
2439
|
+
}
|
|
1687
2440
|
async run() {
|
|
1688
2441
|
this.running = true;
|
|
1689
2442
|
const close = () => {
|
|
@@ -1703,7 +2456,9 @@ export class SalaamApp {
|
|
|
1703
2456
|
this.painter = setInterval(() => {
|
|
1704
2457
|
if (this.client.preview ||
|
|
1705
2458
|
this.client.snapshot?.approvals.some((a) => a.state === 'pending') ||
|
|
1706
|
-
this.client.commands.some((run) => !commandTerminal(run.state))
|
|
2459
|
+
this.client.commands.some((run) => !commandTerminal(run.state)) ||
|
|
2460
|
+
// The progress line counts seconds while a turn runs, so it never shows a stale state.
|
|
2461
|
+
this.client.snapshot?.attempts.some((a) => ['accepted', 'dispatching', 'running'].includes(a.state)))
|
|
1707
2462
|
this.tui.requestRender();
|
|
1708
2463
|
}, 1000);
|
|
1709
2464
|
await this.initialize();
|
|
@@ -1719,6 +2474,8 @@ export class SalaamApp {
|
|
|
1719
2474
|
process.off(signal, close);
|
|
1720
2475
|
process.stdin.off('end', close);
|
|
1721
2476
|
process.stdout.off('error', close);
|
|
2477
|
+
if (this.statusOn)
|
|
2478
|
+
this.herdr.release();
|
|
1722
2479
|
await this.terminal.drainInput(150, 25);
|
|
1723
2480
|
process.stdin.pause();
|
|
1724
2481
|
process.stdin.unref();
|