@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/src/tui.ts
CHANGED
|
@@ -11,9 +11,19 @@ import {
|
|
|
11
11
|
type OverlayHandle,
|
|
12
12
|
type KeyId,
|
|
13
13
|
} from '@earendil-works/pi-tui';
|
|
14
|
+
import { extractMentions, isSensitivePath } from './mentions.js';
|
|
15
|
+
import { MentionAutocompleteProvider } from './tui-mentions.js';
|
|
14
16
|
import { execFile, spawn, type ChildProcess } from 'node:child_process';
|
|
15
17
|
import { promisify } from 'node:util';
|
|
16
18
|
import { homedir } from 'node:os';
|
|
19
|
+
import {
|
|
20
|
+
cancelChoices,
|
|
21
|
+
interactiveCoding,
|
|
22
|
+
queuedItems,
|
|
23
|
+
runningAttempt,
|
|
24
|
+
turnKey,
|
|
25
|
+
turnKeyAction,
|
|
26
|
+
} from './tui-turn-keys.js';
|
|
17
27
|
import { randomUUID } from 'node:crypto';
|
|
18
28
|
import { join, resolve, basename } from 'node:path';
|
|
19
29
|
import {
|
|
@@ -35,26 +45,75 @@ import {
|
|
|
35
45
|
providerRoute,
|
|
36
46
|
type ProviderRoute,
|
|
37
47
|
} from './provider-config.js';
|
|
38
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
INIT_INSTRUCTION,
|
|
50
|
+
SLASH_COMMANDS,
|
|
51
|
+
SLASH_COMPLETIONS,
|
|
52
|
+
parseSlashCommand,
|
|
53
|
+
slashHelp,
|
|
54
|
+
} from './tui-commands.js';
|
|
55
|
+
import {
|
|
56
|
+
branchInstruction,
|
|
57
|
+
commitInstruction,
|
|
58
|
+
currentBranch,
|
|
59
|
+
defaultBase,
|
|
60
|
+
forgeTool,
|
|
61
|
+
loadGitPrefs,
|
|
62
|
+
prInstruction,
|
|
63
|
+
validBranchName,
|
|
64
|
+
} from './git-helpers.js';
|
|
39
65
|
import { conversationExport, writeConversationExport } from './tui-export.js';
|
|
66
|
+
import { describeHooks } from './hooks.js';
|
|
67
|
+
import {
|
|
68
|
+
expandCustomCommand,
|
|
69
|
+
loadCustomCommands,
|
|
70
|
+
matchCustomCommand,
|
|
71
|
+
type CustomCommand,
|
|
72
|
+
} from './custom-commands.js';
|
|
40
73
|
import { credentialPath } from './credentials.js';
|
|
41
74
|
import { call } from './transport.js';
|
|
75
|
+
import { notificationFor, notify, notifyMode, jobsText, type JobRow } from './tui-notify.js';
|
|
76
|
+
import type { DurableEvent } from './protocol.js';
|
|
42
77
|
import { OperatorClient } from './tui-client.js';
|
|
43
78
|
import { SalaamTheme, safeText, shortId, type ThemeName } from './tui-theme.js';
|
|
44
79
|
import {
|
|
45
80
|
Dynamic,
|
|
46
81
|
DocumentView,
|
|
47
82
|
header,
|
|
83
|
+
modelLabel,
|
|
48
84
|
tabs,
|
|
49
85
|
status,
|
|
50
86
|
sidebar,
|
|
51
87
|
type ViewModel,
|
|
52
88
|
type ViewTab,
|
|
53
89
|
type DiffPage,
|
|
90
|
+
modeLabel,
|
|
54
91
|
} from './tui-view.js';
|
|
55
|
-
import {
|
|
92
|
+
import { nextMode } from './autonomy.js';
|
|
93
|
+
import {
|
|
94
|
+
ChoiceModal,
|
|
95
|
+
FormModal,
|
|
96
|
+
ConfirmModal,
|
|
97
|
+
ApprovalModal,
|
|
98
|
+
approvalReason,
|
|
99
|
+
onceOnlyReason,
|
|
100
|
+
TextModal,
|
|
101
|
+
} from './tui-modals.js';
|
|
56
102
|
import type { Approval } from './protocol.js';
|
|
57
103
|
import { commandTerminal } from './coding.js';
|
|
104
|
+
import { rewindItems, rewindSummary } from './tui-review.js';
|
|
105
|
+
import { classifierItems, classifierNotice, type ClassifierView } from './tui-classifier.js';
|
|
106
|
+
import { GLOBAL_KEYS, REVIEW_KEYS, keySheet, type GlobalAction } from './tui-keymap.js';
|
|
107
|
+
import { HerdrReporter, statusEnabled, terminalState, titleFor } from './terminal-status.js';
|
|
108
|
+
import {
|
|
109
|
+
ComposerHistory,
|
|
110
|
+
ctrlCAction,
|
|
111
|
+
historyPath,
|
|
112
|
+
searchHistory,
|
|
113
|
+
searchPrompt,
|
|
114
|
+
shellPassthrough,
|
|
115
|
+
} from './tui-composer.js';
|
|
116
|
+
import type { RewindMode } from './turn-rewind.js';
|
|
58
117
|
|
|
59
118
|
export interface TuiOptions {
|
|
60
119
|
state: string;
|
|
@@ -65,6 +124,10 @@ export interface TuiOptions {
|
|
|
65
124
|
cwd?: string;
|
|
66
125
|
codingRoot?: string;
|
|
67
126
|
codingSocket?: string;
|
|
127
|
+
/** Your own custom commands folder; defaults to ~/.config/salaam/commands. */
|
|
128
|
+
commandsDir?: string;
|
|
129
|
+
/** `salaam code`: open this folder directly (step 0, 2026-10-31). */
|
|
130
|
+
start?: import('./session-pick.js').CodeStart;
|
|
68
131
|
}
|
|
69
132
|
/** Frames are atomic. A slow terminal gets one fresh full redraw after drain. */
|
|
70
133
|
export class SalaamTerminal extends ProcessTerminal {
|
|
@@ -153,6 +216,12 @@ export class SalaamApp {
|
|
|
153
216
|
private lastSession?: string;
|
|
154
217
|
private lastRevision?: number;
|
|
155
218
|
private readonly historyKeys = new Set<string>();
|
|
219
|
+
/** Messages this user sent, kept across sessions without secret-looking lines (Ctrl+R searches it). */
|
|
220
|
+
private readonly sent = new ComposerHistory(historyPath());
|
|
221
|
+
/** When Ctrl+C was last pressed on an empty composer; a second press within 2 s leaves. */
|
|
222
|
+
private ctrlCArmed?: number;
|
|
223
|
+
/** Reverse history search in progress: the query, the matching entry and the draft it replaces. */
|
|
224
|
+
private search?: { query: string; index: number; draft: string };
|
|
156
225
|
private readonly removeInput: () => void;
|
|
157
226
|
constructor(readonly options: TuiOptions) {
|
|
158
227
|
this.client = new OperatorClient(options.state);
|
|
@@ -186,8 +255,9 @@ export class SalaamApp {
|
|
|
186
255
|
);
|
|
187
256
|
// The app journals the exact expanded input before clearing it. The library's submit path trims and clears first.
|
|
188
257
|
this.editor.disableSubmit = true;
|
|
258
|
+
for (const entry of this.sent.entries) this.editor.addToHistory(entry);
|
|
189
259
|
this.editor.setAutocompleteProvider(
|
|
190
|
-
new
|
|
260
|
+
new MentionAutocompleteProvider([...SLASH_COMPLETIONS], options.cwd ?? process.cwd()),
|
|
191
261
|
);
|
|
192
262
|
this.editor.onChange = () => {
|
|
193
263
|
const text = this.editor.getExpandedText(),
|
|
@@ -197,6 +267,8 @@ export class SalaamApp {
|
|
|
197
267
|
return;
|
|
198
268
|
}
|
|
199
269
|
if (this.client.snapshot) this.drafts.set(this.client.snapshot.id, clean);
|
|
270
|
+
// Starting a command reloads this workspace's custom commands into the completion list.
|
|
271
|
+
if (clean === '/') this.refreshCommandCompletions();
|
|
200
272
|
this.tui.requestRender();
|
|
201
273
|
};
|
|
202
274
|
this.document = new DocumentView(this.view);
|
|
@@ -255,6 +327,10 @@ export class SalaamApp {
|
|
|
255
327
|
this.done = new Promise((resolve) => (this.finish = resolve));
|
|
256
328
|
this.removeInput = this.tui.addInputListener((data) => this.input(data));
|
|
257
329
|
this.client.on('change', () => this.changed());
|
|
330
|
+
this.client.on('durable', (event: DurableEvent) => {
|
|
331
|
+
const message = notificationFor(event);
|
|
332
|
+
if (message) notify(message, notifyMode());
|
|
333
|
+
});
|
|
258
334
|
}
|
|
259
335
|
private contentWidth(): number {
|
|
260
336
|
return Math.max(
|
|
@@ -262,8 +338,27 @@ export class SalaamApp {
|
|
|
262
338
|
this.terminal.columns - (this.rail && this.terminal.columns >= 104 ? 31 : 0),
|
|
263
339
|
);
|
|
264
340
|
}
|
|
341
|
+
// Trial polish C: the window title and Herdr say working / needs you / idle.
|
|
342
|
+
private readonly statusOn = statusEnabled();
|
|
343
|
+
private herdr = new HerdrReporter();
|
|
344
|
+
private lastTitle?: string;
|
|
345
|
+
private reportStatus(): void {
|
|
346
|
+
if (!this.statusOn) return;
|
|
347
|
+
const snapshot = this.client.snapshot;
|
|
348
|
+
const state = terminalState(snapshot);
|
|
349
|
+
const name = snapshot
|
|
350
|
+
? (this.client.names[snapshot.id] ?? (snapshot.cwd ? basename(snapshot.cwd) : undefined))
|
|
351
|
+
: undefined;
|
|
352
|
+
const title = titleFor(state, name);
|
|
353
|
+
if (title !== this.lastTitle) {
|
|
354
|
+
this.lastTitle = title;
|
|
355
|
+
this.terminal.setTitle(title);
|
|
356
|
+
}
|
|
357
|
+
this.herdr.report(state, snapshot?.id);
|
|
358
|
+
}
|
|
265
359
|
private changed(): void {
|
|
266
360
|
const snapshot = this.client.snapshot;
|
|
361
|
+
this.reportStatus();
|
|
267
362
|
if (snapshot) {
|
|
268
363
|
if (snapshot.id !== this.lastSession) {
|
|
269
364
|
if (this.lastSession) this.drafts.set(this.lastSession, this.editor.getExpandedText());
|
|
@@ -282,6 +377,11 @@ export class SalaamApp {
|
|
|
282
377
|
this.historyKeys.add(submission.id);
|
|
283
378
|
this.editor.addToHistory(submission.text);
|
|
284
379
|
}
|
|
380
|
+
// Phase 2b: the status line shows this session's mode; reload it when the session changes.
|
|
381
|
+
if (this.client.snapshot?.id !== this.modeSession) {
|
|
382
|
+
this.modeSession = this.client.snapshot?.id;
|
|
383
|
+
void this.loadMode().catch(() => {});
|
|
384
|
+
}
|
|
285
385
|
if (this.client.workspace?.revision !== this.lastRevision) {
|
|
286
386
|
this.lastRevision = this.client.workspace?.revision;
|
|
287
387
|
this.view.diff = undefined;
|
|
@@ -293,19 +393,73 @@ export class SalaamApp {
|
|
|
293
393
|
this.tui.requestRender();
|
|
294
394
|
}
|
|
295
395
|
private input(data: string): { consume: true } | undefined {
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
396
|
+
if (this.search) return this.searchKey(data);
|
|
397
|
+
if (matchesKey(data, 'ctrl+c')) {
|
|
398
|
+
this.ctrlC();
|
|
399
|
+
return { consume: true };
|
|
400
|
+
}
|
|
401
|
+
this.ctrlCArmed = undefined;
|
|
402
|
+
if (matchesKey(data, 'ctrl+d') && !this.editor.getExpandedText()) {
|
|
300
403
|
this.finish();
|
|
301
404
|
return { consume: true };
|
|
302
405
|
}
|
|
303
406
|
if (this.modal) return;
|
|
407
|
+
if (matchesKey(data, 'ctrl+r')) {
|
|
408
|
+
this.startSearch();
|
|
409
|
+
return { consume: true };
|
|
410
|
+
}
|
|
304
411
|
if (
|
|
305
412
|
this.editor.isShowingAutocomplete() &&
|
|
306
413
|
(matchesKey(data, 'enter') || matchesKey(data, 'tab') || matchesKey(data, 'escape'))
|
|
307
414
|
)
|
|
308
415
|
return;
|
|
416
|
+
if (data === '?' && this.editor.getExpandedText() === '') {
|
|
417
|
+
this.help();
|
|
418
|
+
return { consume: true };
|
|
419
|
+
}
|
|
420
|
+
// Esc stops the running turn, Enter steers it, Tab queues behind it (interactive coding only).
|
|
421
|
+
const key = turnKey(data);
|
|
422
|
+
const action = key
|
|
423
|
+
? turnKeyAction({
|
|
424
|
+
key,
|
|
425
|
+
text: this.editor.getExpandedText(),
|
|
426
|
+
snapshot: this.client.snapshot,
|
|
427
|
+
modal: false,
|
|
428
|
+
autocomplete: false,
|
|
429
|
+
})
|
|
430
|
+
: null;
|
|
431
|
+
if (action === 'interrupt') {
|
|
432
|
+
const attempt = runningAttempt(this.client.snapshot);
|
|
433
|
+
if (attempt)
|
|
434
|
+
void this.execute(async () => {
|
|
435
|
+
await this.client.cancel(attempt);
|
|
436
|
+
this.client.report(
|
|
437
|
+
'Stopped the turn. Edits already made stay; /undo reverts the last one. Your typed text is kept.',
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
return { consume: true };
|
|
441
|
+
}
|
|
442
|
+
if (action === 'steer' || action === 'queue') {
|
|
443
|
+
const text = this.editor.getExpandedText(),
|
|
444
|
+
session = this.client.snapshot?.id;
|
|
445
|
+
void this.execute(async () => {
|
|
446
|
+
if (action === 'steer') await this.client.steer(text);
|
|
447
|
+
else {
|
|
448
|
+
await this.client.submit(text);
|
|
449
|
+
const waiting = this.client.snapshot ? queuedItems(this.client.snapshot).length : 0;
|
|
450
|
+
this.client.report(
|
|
451
|
+
'Queued for after this turn (' + waiting + ' waiting). /queue edits or removes them.',
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
this.editor.addToHistory(text);
|
|
455
|
+
this.sent.add(text);
|
|
456
|
+
if (this.client.snapshot?.id === session && this.editor.getExpandedText() === text) {
|
|
457
|
+
this.editor.setText('');
|
|
458
|
+
if (session) this.drafts.delete(session);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
return { consume: true };
|
|
462
|
+
}
|
|
309
463
|
if (matchesKey(data, 'enter')) {
|
|
310
464
|
const text = this.editor.getExpandedText(),
|
|
311
465
|
session = this.client.snapshot?.id;
|
|
@@ -321,24 +475,11 @@ export class SalaamApp {
|
|
|
321
475
|
else submit();
|
|
322
476
|
return { consume: true };
|
|
323
477
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
['ctrl+g', () => this.setTab('changes')],
|
|
330
|
-
['ctrl+t', () => this.setTab('commands')],
|
|
331
|
-
[
|
|
332
|
-
'ctrl+b',
|
|
333
|
-
() => {
|
|
334
|
-
this.rail = !this.rail;
|
|
335
|
-
this.tui.requestRender(true);
|
|
336
|
-
},
|
|
337
|
-
],
|
|
338
|
-
['ctrl+r', () => void this.execute(() => this.refresh())],
|
|
339
|
-
['ctrl+e', () => void this.execute(() => this.editPrompt())],
|
|
340
|
-
['f1', () => this.help()],
|
|
341
|
-
];
|
|
478
|
+
// Ctrl+K kills to the end of the line while the composer has text (readline); on an empty
|
|
479
|
+
// composer it opens the actions palette. F2 always opens it.
|
|
480
|
+
if (matchesKey(data, 'ctrl+k') && this.editor.getExpandedText() !== '') return;
|
|
481
|
+
const actions = this.globalActions();
|
|
482
|
+
const keys: [KeyId, () => void][] = GLOBAL_KEYS.map((k) => [k.key, () => actions[k.action]()]);
|
|
342
483
|
for (const [key, action] of keys)
|
|
343
484
|
if (matchesKey(data, key)) {
|
|
344
485
|
if (this.view.busy) this.pendingAction = action;
|
|
@@ -352,6 +493,10 @@ export class SalaamApp {
|
|
|
352
493
|
this.setTab(names[(names.indexOf(this.view.tab) + 1) % names.length]!);
|
|
353
494
|
return { consume: true };
|
|
354
495
|
}
|
|
496
|
+
if (this.view.tab === 'changes' && interactiveCoding(this.client.snapshot)) {
|
|
497
|
+
const handled = this.reviewKey(data);
|
|
498
|
+
if (handled) return handled;
|
|
499
|
+
}
|
|
355
500
|
if (
|
|
356
501
|
this.view.tab === 'changes' &&
|
|
357
502
|
(matchesKey(data, 'alt+right') || matchesKey(data, 'alt+left'))
|
|
@@ -364,6 +509,30 @@ export class SalaamApp {
|
|
|
364
509
|
}
|
|
365
510
|
return;
|
|
366
511
|
}
|
|
512
|
+
/** What each key in GLOBAL_KEYS does; the table owns the keys, this owns the actions. */
|
|
513
|
+
private globalActions(): Record<GlobalAction, () => void> {
|
|
514
|
+
return (this.actionTable ??= {
|
|
515
|
+
palette: () => this.palette(),
|
|
516
|
+
sessions: () => void this.execute(() => this.sessions()),
|
|
517
|
+
new: () => void this.execute(() => this.newSession()),
|
|
518
|
+
approvals: () => this.approvals(),
|
|
519
|
+
changes: () => this.setTab('changes'),
|
|
520
|
+
commands: () => this.setTab('commands'),
|
|
521
|
+
rail: () => {
|
|
522
|
+
this.rail = !this.rail;
|
|
523
|
+
this.tui.requestRender(true);
|
|
524
|
+
},
|
|
525
|
+
transcript: () => {
|
|
526
|
+
this.view.transcript = !this.view.transcript;
|
|
527
|
+
this.setTab('conversation');
|
|
528
|
+
},
|
|
529
|
+
refresh: () => void this.execute(() => this.refresh()),
|
|
530
|
+
editor: () => void this.execute(() => this.editPrompt()),
|
|
531
|
+
mode: () => void this.execute(() => this.cycleMode()),
|
|
532
|
+
help: () => this.help(),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
private actionTable?: Record<GlobalAction, () => void>;
|
|
367
536
|
private async execute(work: () => Promise<unknown>): Promise<void> {
|
|
368
537
|
if (this.view.busy) {
|
|
369
538
|
const session = this.client.snapshot?.id ?? this.options.session;
|
|
@@ -426,6 +595,7 @@ export class SalaamApp {
|
|
|
426
595
|
description: string,
|
|
427
596
|
label: string,
|
|
428
597
|
confirm: () => Promise<unknown>,
|
|
598
|
+
confirmByDefault = false,
|
|
429
599
|
): void {
|
|
430
600
|
if (this.terminal.rows < 18) {
|
|
431
601
|
this.client.report('Expand the terminal to at least 18 rows to review this action.');
|
|
@@ -443,6 +613,7 @@ export class SalaamApp {
|
|
|
443
613
|
this.closeModal();
|
|
444
614
|
void this.execute(confirm);
|
|
445
615
|
},
|
|
616
|
+
confirmByDefault,
|
|
446
617
|
),
|
|
447
618
|
);
|
|
448
619
|
}
|
|
@@ -476,25 +647,51 @@ export class SalaamApp {
|
|
|
476
647
|
if (this.client.connection === 'offline')
|
|
477
648
|
throw new Error('Service did not become available. Inspect sal service status.');
|
|
478
649
|
}
|
|
650
|
+
/** Interactive sessions switch in place between turns; otherwise the choice opens a new session. */
|
|
651
|
+
private async applyRoute(route: import('./provider-config.js').ProviderRoute): Promise<void> {
|
|
652
|
+
if (this.client.snapshot?.provider_route && this.client.controlling) {
|
|
653
|
+
try {
|
|
654
|
+
await this.client.switchRoute(route);
|
|
655
|
+
this.client.notice = 'Now using ' + modelLabel(this.client) + ' in this session.';
|
|
656
|
+
return;
|
|
657
|
+
} catch (error) {
|
|
658
|
+
// A route that limits output differently cannot join this session. Keep the choice and
|
|
659
|
+
// open the new session with it, rather than leaving the old model selected.
|
|
660
|
+
if ((error as { code?: string }).code !== 'provider_output_cap_changed') throw error;
|
|
661
|
+
this.selectedRoute = route;
|
|
662
|
+
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.`;
|
|
663
|
+
await this.newSession();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
this.selectedRoute = route;
|
|
668
|
+
await this.newSession();
|
|
669
|
+
}
|
|
479
670
|
private modelPicker(): void {
|
|
671
|
+
const configured = (this.client.snapshot?.provider_route ?? this.client.health?.provider_route)
|
|
672
|
+
?.provider;
|
|
673
|
+
const live = PI_PROVIDERS.filter((p) => p.getModels().length);
|
|
480
674
|
this.choice(
|
|
481
|
-
'Provider ·
|
|
675
|
+
this.client.snapshot ? 'Provider · switches this session' : 'Provider',
|
|
482
676
|
[
|
|
677
|
+
// The configured provider first; the scripted demo last, never the default.
|
|
678
|
+
...[
|
|
679
|
+
...live.filter((p) => p.id === configured),
|
|
680
|
+
...live.filter((p) => p.id !== configured),
|
|
681
|
+
].map((p) => ({
|
|
682
|
+
value: p.id,
|
|
683
|
+
label: p.name,
|
|
684
|
+
description: p.id + (p.id === configured ? ' · current' : ''),
|
|
685
|
+
})),
|
|
483
686
|
{
|
|
484
687
|
value: 'offline',
|
|
485
688
|
label: 'Offline demo',
|
|
486
689
|
description: 'Scripted fixture, not an AI model',
|
|
487
690
|
},
|
|
488
|
-
...PI_PROVIDERS.filter((p) => p.getModels().length).map((p) => ({
|
|
489
|
-
value: p.id,
|
|
490
|
-
label: p.name,
|
|
491
|
-
description: p.id,
|
|
492
|
-
})),
|
|
493
691
|
],
|
|
494
692
|
(provider) => {
|
|
495
693
|
if (provider === 'offline') {
|
|
496
|
-
this.
|
|
497
|
-
void this.execute(() => this.newSession());
|
|
694
|
+
void this.execute(() => this.applyRoute(providerRoute()));
|
|
498
695
|
return;
|
|
499
696
|
}
|
|
500
697
|
const catalog = PI_PROVIDERS.find((p) => p.id === provider)!;
|
|
@@ -505,14 +702,100 @@ export class SalaamApp {
|
|
|
505
702
|
label: m.name,
|
|
506
703
|
description: m.id + ' · ' + m.contextWindow + ' context tokens',
|
|
507
704
|
})),
|
|
508
|
-
(model) =>
|
|
509
|
-
this.selectedRoute = providerRoute(provider, model);
|
|
510
|
-
void this.execute(() => this.newSession());
|
|
511
|
-
},
|
|
705
|
+
(model) => void this.execute(() => this.applyRoute(providerRoute(provider, model))),
|
|
512
706
|
);
|
|
513
707
|
},
|
|
514
708
|
);
|
|
515
709
|
}
|
|
710
|
+
private modeSession: string | undefined;
|
|
711
|
+
/** Phase 2b: the session's mode and full access, for the status line. */
|
|
712
|
+
private async loadMode(): Promise<void> {
|
|
713
|
+
if (!this.client.snapshot) {
|
|
714
|
+
this.view.mode = undefined;
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const current = await this.client.permissions();
|
|
718
|
+
this.view.mode =
|
|
719
|
+
current.configurable && current.coding
|
|
720
|
+
? {
|
|
721
|
+
mode: current.policy.mode,
|
|
722
|
+
...(current.policy.access ? { access: current.policy.access } : {}),
|
|
723
|
+
...(current.command_sandbox ? { sandbox: current.command_sandbox } : {}),
|
|
724
|
+
}
|
|
725
|
+
: undefined;
|
|
726
|
+
this.tui.requestRender();
|
|
727
|
+
}
|
|
728
|
+
/** Shift+Tab: review → accept edits → auto. Full access and YOLO are kept as they are; each has
|
|
729
|
+
* its own command. */
|
|
730
|
+
private async cycleMode(): Promise<void> {
|
|
731
|
+
const session = this.client.snapshot?.id;
|
|
732
|
+
if (!session) throw new Error('Open a session first.');
|
|
733
|
+
const current = await this.client.permissions();
|
|
734
|
+
if (!current.configurable || !current.coding)
|
|
735
|
+
throw new Error('Modes are for local coding sessions. No session or files were changed.');
|
|
736
|
+
await this.client.setPermissions(session, current.policy.revision, {
|
|
737
|
+
mode: nextMode(current.policy.mode),
|
|
738
|
+
paths: [],
|
|
739
|
+
...(current.policy.access ? { access: current.policy.access } : {}),
|
|
740
|
+
});
|
|
741
|
+
await this.loadMode();
|
|
742
|
+
}
|
|
743
|
+
private async setAccess(access: 'full' | 'yolo' | undefined): Promise<void> {
|
|
744
|
+
const session = this.client.snapshot!.id;
|
|
745
|
+
const current = await this.client.permissions();
|
|
746
|
+
await this.client.setPermissions(session, current.policy.revision, {
|
|
747
|
+
mode: current.policy.mode,
|
|
748
|
+
paths: current.policy.paths,
|
|
749
|
+
...(access ? { access } : {}),
|
|
750
|
+
});
|
|
751
|
+
await this.loadMode();
|
|
752
|
+
}
|
|
753
|
+
/** Full access on (with a warning and a confirm) or off (at once). From YOLO it steps down at once. */
|
|
754
|
+
private async fullAccess(): Promise<void> {
|
|
755
|
+
const session = this.client.snapshot?.id;
|
|
756
|
+
if (!session) throw new Error('Open a session first.');
|
|
757
|
+
const current = await this.client.permissions();
|
|
758
|
+
if (!current.configurable || !current.coding)
|
|
759
|
+
throw new Error('Full access is for local coding sessions. Nothing was changed.');
|
|
760
|
+
if (current.policy.access === 'full') return this.setAccess(undefined);
|
|
761
|
+
if (current.full_access_allowed === false)
|
|
762
|
+
throw new Error(
|
|
763
|
+
'The operator does not allow full access on this host. Commands stay in the OS sandbox.',
|
|
764
|
+
);
|
|
765
|
+
if (current.policy.access === 'yolo') return this.setAccess('full');
|
|
766
|
+
this.confirm(
|
|
767
|
+
'Turn on full access?',
|
|
768
|
+
'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.',
|
|
769
|
+
'turn on full access',
|
|
770
|
+
() => this.setAccess('full'),
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
/** YOLO on only after the person types "yolo"; off at once. Never in the Shift+Tab cycle. */
|
|
774
|
+
private async yolo(): Promise<void> {
|
|
775
|
+
const session = this.client.snapshot?.id;
|
|
776
|
+
if (!session) throw new Error('Open a session first.');
|
|
777
|
+
const current = await this.client.permissions();
|
|
778
|
+
if (!current.configurable || !current.coding)
|
|
779
|
+
throw new Error('YOLO is for local coding sessions. Nothing was changed.');
|
|
780
|
+
if (current.policy.access === 'yolo') return this.setAccess(undefined);
|
|
781
|
+
if (current.yolo_allowed === false)
|
|
782
|
+
throw new Error(
|
|
783
|
+
'YOLO is not allowed for this session: it is only for your own desk sessions on a host whose operator allows it.',
|
|
784
|
+
);
|
|
785
|
+
this.taskInput(
|
|
786
|
+
'Turn on YOLO? Type yolo to confirm',
|
|
787
|
+
'',
|
|
788
|
+
'Type yolo',
|
|
789
|
+
'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.',
|
|
790
|
+
(value) => {
|
|
791
|
+
if (value.trim() !== 'yolo') {
|
|
792
|
+
this.client.report('YOLO stays off: the confirmation was not "yolo".');
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
void this.execute(() => this.setAccess('yolo'));
|
|
796
|
+
},
|
|
797
|
+
);
|
|
798
|
+
}
|
|
516
799
|
private showText(title: string, text: string): void {
|
|
517
800
|
this.showModal(
|
|
518
801
|
new TextModal(
|
|
@@ -536,7 +819,48 @@ export class SalaamApp {
|
|
|
536
819
|
return;
|
|
537
820
|
}
|
|
538
821
|
if (command === 'help') {
|
|
539
|
-
|
|
822
|
+
const custom = args ? [] : this.customCommands();
|
|
823
|
+
const problems = args ? [] : this.customCommandProblems;
|
|
824
|
+
this.showText(
|
|
825
|
+
'Salaam commands',
|
|
826
|
+
slashHelp(args || undefined) +
|
|
827
|
+
(custom.length
|
|
828
|
+
? '\n\nCustom commands (write a prompt only):\n' +
|
|
829
|
+
custom
|
|
830
|
+
.map(
|
|
831
|
+
(c) =>
|
|
832
|
+
`/${c.name}${c.argumentHint ? ' ' + c.argumentHint : ''} — ${c.description} [${c.scope}]`,
|
|
833
|
+
)
|
|
834
|
+
.join('\n')
|
|
835
|
+
: '') +
|
|
836
|
+
(problems.length ? '\n\nSkipped command files:\n' + problems.join('\n') : ''),
|
|
837
|
+
);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (command === 'hooks') {
|
|
841
|
+
if (args === 'revoke') {
|
|
842
|
+
await this.client.revokeHooks();
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const { sources } = await this.client.hooks();
|
|
846
|
+
if (args === 'trust') {
|
|
847
|
+
const repo = sources.find((s) => s.scope === 'repo');
|
|
848
|
+
if (!repo?.digest) throw new Error('This workspace has no readable .salaam/hooks.json.');
|
|
849
|
+
if (repo.trusted) {
|
|
850
|
+
this.showText('Hooks', 'This workspace’s hooks are already trusted as they are.');
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
this.confirm(
|
|
854
|
+
'Trust this workspace’s hooks?',
|
|
855
|
+
describeHooks([repo]) +
|
|
856
|
+
'\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.',
|
|
857
|
+
'Trust these hooks',
|
|
858
|
+
() => this.client.trustHooks(repo.digest!),
|
|
859
|
+
);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (args) throw new Error('Use /hooks, /hooks trust or /hooks revoke.');
|
|
863
|
+
this.showText('Hooks', describeHooks(sources));
|
|
540
864
|
return;
|
|
541
865
|
}
|
|
542
866
|
if (command === 'keys') {
|
|
@@ -549,10 +873,8 @@ export class SalaamApp {
|
|
|
549
873
|
return;
|
|
550
874
|
}
|
|
551
875
|
const slash = args.indexOf('/');
|
|
552
|
-
if (slash <= 0)
|
|
553
|
-
|
|
554
|
-
this.selectedRoute = providerRoute(args.slice(0, slash), args.slice(slash + 1));
|
|
555
|
-
await this.newSession();
|
|
876
|
+
if (slash <= 0) throw new Error('Use /model provider/model.');
|
|
877
|
+
await this.applyRoute(providerRoute(args.slice(0, slash), args.slice(slash + 1)));
|
|
556
878
|
return;
|
|
557
879
|
}
|
|
558
880
|
if (command === 'provider') {
|
|
@@ -561,8 +883,7 @@ export class SalaamApp {
|
|
|
561
883
|
return;
|
|
562
884
|
}
|
|
563
885
|
if (args === 'offline') {
|
|
564
|
-
this.
|
|
565
|
-
await this.newSession();
|
|
886
|
+
await this.applyRoute(providerRoute());
|
|
566
887
|
return;
|
|
567
888
|
}
|
|
568
889
|
const provider = PI_PROVIDERS.find((p) => p.id === args);
|
|
@@ -571,10 +892,7 @@ export class SalaamApp {
|
|
|
571
892
|
this.choice(
|
|
572
893
|
'Model · ' + provider.name,
|
|
573
894
|
provider.getModels().map((m) => ({ value: m.id, label: m.name, description: m.id })),
|
|
574
|
-
(model) =>
|
|
575
|
-
this.selectedRoute = providerRoute(args, model);
|
|
576
|
-
void this.execute(() => this.newSession());
|
|
577
|
-
},
|
|
895
|
+
(model) => void this.execute(() => this.applyRoute(providerRoute(args, model))),
|
|
578
896
|
);
|
|
579
897
|
return;
|
|
580
898
|
}
|
|
@@ -602,7 +920,7 @@ export class SalaamApp {
|
|
|
602
920
|
[
|
|
603
921
|
{ value: 'permissions', label: 'Permissions · inspect, review or scoped edits' },
|
|
604
922
|
{ value: 'progression', label: 'Progression · manual, bounded tasks or schedules' },
|
|
605
|
-
{ value: 'model', label: 'Provider and model
|
|
923
|
+
{ value: 'model', label: 'Provider and model' },
|
|
606
924
|
{ value: 'theme', label: 'Switch Night / Paper' },
|
|
607
925
|
{ value: 'rail', label: 'Toggle workspace rail' },
|
|
608
926
|
{ value: 'keys', label: 'Keyboard shortcuts' },
|
|
@@ -639,6 +957,10 @@ export class SalaamApp {
|
|
|
639
957
|
return;
|
|
640
958
|
}
|
|
641
959
|
if (!snapshot) throw new Error('Choose a session with /sessions or /new first.');
|
|
960
|
+
if (command === 'rewind') {
|
|
961
|
+
await this.rewind(args);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
642
964
|
if (command === 'undo') {
|
|
643
965
|
if (args === 'reconcile') {
|
|
644
966
|
await this.client.reconcileUndo();
|
|
@@ -682,6 +1004,36 @@ export class SalaamApp {
|
|
|
682
1004
|
);
|
|
683
1005
|
return;
|
|
684
1006
|
}
|
|
1007
|
+
if (command === 'commit' || command === 'branch' || command === 'pr') {
|
|
1008
|
+
if (!snapshot.capabilities.includes('local-coding-reviewed'))
|
|
1009
|
+
throw new Error(`/${command} runs git. Start a coding session with /coding first.`);
|
|
1010
|
+
const root = snapshot.cwd;
|
|
1011
|
+
let instruction: string;
|
|
1012
|
+
if (command === 'commit')
|
|
1013
|
+
instruction = commitInstruction({ ...loadGitPrefs(), session: snapshot.id });
|
|
1014
|
+
else if (command === 'branch') {
|
|
1015
|
+
if (!args) throw new Error('Usage: /branch <name>');
|
|
1016
|
+
instruction = branchInstruction(args);
|
|
1017
|
+
} else {
|
|
1018
|
+
const branch = currentBranch(root),
|
|
1019
|
+
base = args || defaultBase(root);
|
|
1020
|
+
if (!branch) throw new Error('/pr needs a checked-out branch, not a detached HEAD.');
|
|
1021
|
+
if (branch === base)
|
|
1022
|
+
throw new Error(`You are on ${base}. Create a branch first with /branch <name>.`);
|
|
1023
|
+
if (!validBranchName(base)) throw new Error(`"${base}" is not a valid branch name.`);
|
|
1024
|
+
instruction = prInstruction({ tool: forgeTool(root), branch, base });
|
|
1025
|
+
}
|
|
1026
|
+
await this.client.submit(instruction);
|
|
1027
|
+
this.setTab('conversation');
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (command === 'init') {
|
|
1031
|
+
if (!snapshot.capabilities.includes('local-coding-reviewed'))
|
|
1032
|
+
throw new Error('/init writes a file. Start a coding session with /coding first.');
|
|
1033
|
+
await this.client.submit(INIT_INSTRUCTION);
|
|
1034
|
+
this.setTab('conversation');
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
685
1037
|
if (command === 'compact') {
|
|
686
1038
|
await this.client.compact();
|
|
687
1039
|
return;
|
|
@@ -695,7 +1047,9 @@ export class SalaamApp {
|
|
|
695
1047
|
return;
|
|
696
1048
|
}
|
|
697
1049
|
if (command === 'diff') {
|
|
698
|
-
|
|
1050
|
+
// Coding sessions review their diff file by file in Changes (Phase 3a).
|
|
1051
|
+
if (snapshot.capabilities.includes('local-coding-reviewed')) this.setTab('changes');
|
|
1052
|
+
else if (snapshot.capabilities.includes('local-workspace-read')) {
|
|
699
1053
|
const diff = await call<{ text: string }>(
|
|
700
1054
|
this.client.socket,
|
|
701
1055
|
`/v1/sessions/${snapshot.id}/local-diff`,
|
|
@@ -712,10 +1066,59 @@ export class SalaamApp {
|
|
|
712
1066
|
this.taskActions();
|
|
713
1067
|
return;
|
|
714
1068
|
}
|
|
715
|
-
if (command === '
|
|
716
|
-
const
|
|
717
|
-
|
|
1069
|
+
if (command === 'classifier') {
|
|
1070
|
+
const path = `/v1/sessions/${snapshot.id}/danger-classifier`;
|
|
1071
|
+
const set = async (choice: string) => {
|
|
1072
|
+
const v = await call<ClassifierView>(this.client.socket, path, {
|
|
1073
|
+
...this.client.actor(),
|
|
1074
|
+
choice,
|
|
1075
|
+
});
|
|
1076
|
+
this.client.notice = classifierNotice(v);
|
|
1077
|
+
this.tui.requestRender();
|
|
1078
|
+
};
|
|
1079
|
+
const want = args.trim();
|
|
1080
|
+
if (want === 'on') {
|
|
1081
|
+
const v = await call<ClassifierView>(this.client.socket, path, {
|
|
1082
|
+
...this.client.actor(),
|
|
1083
|
+
enabled: true,
|
|
1084
|
+
});
|
|
1085
|
+
this.client.notice = classifierNotice(v);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (want) {
|
|
1089
|
+
await set(want);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const v = await call<ClassifierView>(this.client.socket, path);
|
|
1093
|
+
const items = classifierItems(v);
|
|
1094
|
+
if (!items.length) {
|
|
1095
|
+
this.client.notice = classifierNotice(v);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
this.choice('Command reviewer · ' + classifierNotice(v).split('.')[0], items, (choice) => {
|
|
1099
|
+
void this.execute(() => set(choice));
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (command === 'jobs') {
|
|
1104
|
+
const base = `/v1/sessions/${snapshot.id}/local-tasks`;
|
|
1105
|
+
const stop = /^stop\s+(\S+)$/.exec(args.trim());
|
|
1106
|
+
if (stop) {
|
|
1107
|
+
await call(this.client.socket, `${base}/${stop[1]}/stop`, {});
|
|
1108
|
+
this.client.notice = 'Stopping background command ' + stop[1];
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
const { tasks } = await call<{ tasks: JobRow[] }>(this.client.socket, base);
|
|
1112
|
+
const outputs = await Promise.all(
|
|
1113
|
+
tasks
|
|
1114
|
+
.slice(0, 4)
|
|
1115
|
+
.map((t) => call<{ output: string }>(this.client.socket, `${base}/${t.id}`)),
|
|
718
1116
|
);
|
|
1117
|
+
this.showText('Background commands', jobsText(tasks, outputs));
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
if (command === 'cancel') {
|
|
1121
|
+
const active = cancelChoices(snapshot);
|
|
719
1122
|
if (!active.length) {
|
|
720
1123
|
this.client.report('No active or queued instruction to cancel.');
|
|
721
1124
|
return;
|
|
@@ -723,15 +1126,18 @@ export class SalaamApp {
|
|
|
723
1126
|
const cancel = (id: string) =>
|
|
724
1127
|
this.confirm(
|
|
725
1128
|
'Cancel instruction',
|
|
726
|
-
'Cancel attempt ' +
|
|
1129
|
+
'Cancel attempt ' +
|
|
1130
|
+
shortId(id) +
|
|
1131
|
+
'? Your typed text is kept. /exit would leave it running.',
|
|
727
1132
|
'Cancel work',
|
|
728
1133
|
() => this.client.cancel(id),
|
|
1134
|
+
true,
|
|
729
1135
|
);
|
|
730
1136
|
if (active.length === 1) cancel(active[0]!.id);
|
|
731
1137
|
else
|
|
732
1138
|
this.choice(
|
|
733
1139
|
'Cancel an instruction',
|
|
734
|
-
active.map((a) => ({ value: a.id, label: a.
|
|
1140
|
+
active.map((a) => ({ value: a.id, label: a.label })),
|
|
735
1141
|
cancel,
|
|
736
1142
|
);
|
|
737
1143
|
return;
|
|
@@ -746,15 +1152,29 @@ export class SalaamApp {
|
|
|
746
1152
|
await this.client.submit(args);
|
|
747
1153
|
return;
|
|
748
1154
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
)
|
|
1155
|
+
const queued = queuedItems(snapshot);
|
|
1156
|
+
if (!queued.length) {
|
|
1157
|
+
this.client.report('Nothing is queued. Tab queues text for after the running turn.');
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
this.choice(
|
|
1161
|
+
'Queued instructions',
|
|
1162
|
+
queued.map((q) => ({ value: q.attempt, label: q.text.slice(0, 80) })),
|
|
1163
|
+
(attempt) => {
|
|
1164
|
+
const item = queued.find((q) => q.attempt === attempt)!;
|
|
1165
|
+
this.choice(
|
|
1166
|
+
'Queued instruction',
|
|
1167
|
+
[
|
|
1168
|
+
{ value: 'edit', label: 'Edit', description: 'Move it back into the input' },
|
|
1169
|
+
{ value: 'remove', label: 'Remove', description: 'Cancel it before it runs' },
|
|
1170
|
+
],
|
|
1171
|
+
(choice) =>
|
|
1172
|
+
void this.execute(async () => {
|
|
1173
|
+
await this.client.cancel(item.attempt);
|
|
1174
|
+
if (choice === 'edit') this.editor.setText(item.text);
|
|
1175
|
+
}),
|
|
1176
|
+
);
|
|
1177
|
+
},
|
|
758
1178
|
);
|
|
759
1179
|
return;
|
|
760
1180
|
}
|
|
@@ -782,6 +1202,18 @@ export class SalaamApp {
|
|
|
782
1202
|
);
|
|
783
1203
|
return;
|
|
784
1204
|
}
|
|
1205
|
+
if (command === 'mode') {
|
|
1206
|
+
await this.cycleMode();
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (command === 'full-access') {
|
|
1210
|
+
await this.fullAccess();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (command === 'yolo') {
|
|
1214
|
+
await this.yolo();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
785
1217
|
if (command === 'permissions') {
|
|
786
1218
|
await this.permissionControls();
|
|
787
1219
|
return;
|
|
@@ -911,21 +1343,21 @@ export class SalaamApp {
|
|
|
911
1343
|
const current = snapshot.provider_route;
|
|
912
1344
|
if (!current || current.provider === 'salaam-proof')
|
|
913
1345
|
throw new Error('Choose a live provider/model first with /model.');
|
|
914
|
-
const choose = (level: string) =>
|
|
915
|
-
this.
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1346
|
+
const choose = (level: string) =>
|
|
1347
|
+
this.applyRoute(
|
|
1348
|
+
providerRoute(
|
|
1349
|
+
current.provider,
|
|
1350
|
+
current.model,
|
|
1351
|
+
current.max_tokens,
|
|
1352
|
+
current.workspace_id,
|
|
1353
|
+
current.connection,
|
|
1354
|
+
level as import('./provider-config.js').ReasoningLevel,
|
|
1355
|
+
),
|
|
922
1356
|
);
|
|
923
|
-
return this.newSession(snapshot.capabilities.includes('local-coding-reviewed'));
|
|
924
|
-
};
|
|
925
1357
|
if (args) await choose(args);
|
|
926
1358
|
else
|
|
927
1359
|
this.choice(
|
|
928
|
-
'Reasoning ·
|
|
1360
|
+
'Reasoning · current ' + (current.reasoning ?? 'automatic low'),
|
|
929
1361
|
REASONING_LEVELS.map((level) => ({ value: level, label: level })),
|
|
930
1362
|
(level) => void this.execute(() => choose(level)),
|
|
931
1363
|
);
|
|
@@ -974,9 +1406,7 @@ export class SalaamApp {
|
|
|
974
1406
|
await this.startService();
|
|
975
1407
|
this.showModal(
|
|
976
1408
|
new FormModal(
|
|
977
|
-
(localCoding
|
|
978
|
-
? 'New coding session · reviewed edits and unsandboxed commands · '
|
|
979
|
-
: 'New session · ') +
|
|
1409
|
+
(localCoding ? 'New coding session · reviewed edits and commands · ' : 'New session · ') +
|
|
980
1410
|
(this.selectedRoute?.model ?? this.client.health?.provider_route.model ?? 'default'),
|
|
981
1411
|
this.options.cwd ?? this.client.snapshot?.cwd ?? process.cwd(),
|
|
982
1412
|
this.view.theme,
|
|
@@ -994,19 +1424,28 @@ export class SalaamApp {
|
|
|
994
1424
|
);
|
|
995
1425
|
}
|
|
996
1426
|
private async sessions(): Promise<void> {
|
|
1427
|
+
const { pickerOrder, sessionDescription } = await import('./session-pick.js');
|
|
997
1428
|
await this.client.list();
|
|
1429
|
+
const now = Date.now();
|
|
998
1430
|
this.choice(
|
|
999
1431
|
'Your Salaam sessions',
|
|
1000
1432
|
[
|
|
1001
1433
|
{ value: 'new', label: '+ New session', description: 'Start a fresh workspace' },
|
|
1002
|
-
...this.client.sessions.
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1434
|
+
...pickerOrder(this.client.sessions, resolve(this.options.cwd ?? process.cwd())).map(
|
|
1435
|
+
(session) => ({
|
|
1436
|
+
value: session.id,
|
|
1437
|
+
label:
|
|
1438
|
+
(this.client.names[session.id] ?? basename(session.cwd)) + ' ' + shortId(session.id),
|
|
1439
|
+
description: sessionDescription(session, now),
|
|
1440
|
+
}),
|
|
1441
|
+
),
|
|
1008
1442
|
],
|
|
1009
1443
|
(value) => {
|
|
1444
|
+
if (value === 'transcript') {
|
|
1445
|
+
this.view.transcript = !this.view.transcript;
|
|
1446
|
+
this.setTab('conversation');
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1010
1449
|
if (value === 'model') this.modelPicker();
|
|
1011
1450
|
else if (value === 'help') void this.execute(() => this.runSlashCommand('help'));
|
|
1012
1451
|
else if (value === 'coding') void this.execute(() => this.newSession(true));
|
|
@@ -1022,11 +1461,71 @@ export class SalaamApp {
|
|
|
1022
1461
|
},
|
|
1023
1462
|
);
|
|
1024
1463
|
}
|
|
1464
|
+
private commandCache?: { root: string; at: number; commands: CustomCommand[] };
|
|
1465
|
+
private customCommandProblems: string[] = [];
|
|
1466
|
+
/** This workspace's and your own custom commands, reread at most every two seconds. */
|
|
1467
|
+
customCommands(): CustomCommand[] {
|
|
1468
|
+
const root = this.client.snapshot?.cwd ?? this.options.cwd ?? process.cwd();
|
|
1469
|
+
const cached = this.commandCache;
|
|
1470
|
+
if (cached && cached.root === root && Date.now() - cached.at < 2000) return cached.commands;
|
|
1471
|
+
const { commands, problems } = loadCustomCommands(root, this.options.commandsDir);
|
|
1472
|
+
this.customCommandProblems = problems;
|
|
1473
|
+
this.commandCache = { root, at: Date.now(), commands };
|
|
1474
|
+
return commands;
|
|
1475
|
+
}
|
|
1476
|
+
private refreshCommandCompletions(): void {
|
|
1477
|
+
const root = this.client.snapshot?.cwd ?? this.options.cwd ?? process.cwd();
|
|
1478
|
+
this.editor.setAutocompleteProvider(
|
|
1479
|
+
new MentionAutocompleteProvider(
|
|
1480
|
+
[
|
|
1481
|
+
...SLASH_COMPLETIONS,
|
|
1482
|
+
...this.customCommands().map((c) => ({
|
|
1483
|
+
name: c.name,
|
|
1484
|
+
description: `${c.description} [${c.scope}]`,
|
|
1485
|
+
})),
|
|
1486
|
+
],
|
|
1487
|
+
root,
|
|
1488
|
+
),
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1025
1491
|
private async submit(): Promise<void> {
|
|
1026
1492
|
const text = this.editor.getExpandedText(),
|
|
1027
1493
|
session = this.client.snapshot?.id,
|
|
1028
1494
|
continuation = this.view.continuation;
|
|
1029
1495
|
if (!text.trim()) return;
|
|
1496
|
+
const shell = shellPassthrough(text);
|
|
1497
|
+
if (shell) {
|
|
1498
|
+
if (!session) throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
|
|
1499
|
+
if (!interactiveCoding(this.client.snapshot))
|
|
1500
|
+
throw new Error('!command works in local coding sessions (salaam code).');
|
|
1501
|
+
await this.deliver(shell, session, continuation, false, text);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
// A custom command only writes a prompt; built-in names never reach here (they are refused at
|
|
1505
|
+
// load), and the expanded prompt goes through every check an ordinary message does.
|
|
1506
|
+
const custom = text.trim().startsWith('/')
|
|
1507
|
+
? matchCustomCommand(text, this.customCommands())
|
|
1508
|
+
: null;
|
|
1509
|
+
if (custom) {
|
|
1510
|
+
if (!session) throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
|
|
1511
|
+
const prompt = expandCustomCommand(custom.command, custom.args);
|
|
1512
|
+
if (Buffer.byteLength(prompt) > 65536)
|
|
1513
|
+
throw new Error(`/${custom.command.name} expands past 64 KiB. Shorten the command file.`);
|
|
1514
|
+
const secrets = extractMentions(prompt).filter(isSensitivePath);
|
|
1515
|
+
const send = (sensitive: boolean) =>
|
|
1516
|
+
this.deliver(prompt, session, continuation, sensitive, text);
|
|
1517
|
+
if (secrets.length) {
|
|
1518
|
+
this.confirm(
|
|
1519
|
+
'Attach secret-looking files?',
|
|
1520
|
+
`/${custom.command.name} mentions ${secrets.map((s) => '@' + s).join(', ')}, which will be sent to the model in full.`,
|
|
1521
|
+
'Attach and send',
|
|
1522
|
+
() => send(true),
|
|
1523
|
+
);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
await send(false);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1030
1529
|
const command = parseSlashCommand(text);
|
|
1031
1530
|
if (command) {
|
|
1032
1531
|
await this.runSlashCommand(command.name, command.args);
|
|
@@ -1037,9 +1536,30 @@ export class SalaamApp {
|
|
|
1037
1536
|
if (!session) throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
|
|
1038
1537
|
if (Buffer.byteLength(text) > 65536)
|
|
1039
1538
|
throw new Error('The composer accepts at most 64 KiB. Use an artifact for longer input.');
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1539
|
+
const secrets = extractMentions(text).filter(isSensitivePath);
|
|
1540
|
+
if (secrets.length) {
|
|
1541
|
+
this.confirm(
|
|
1542
|
+
'Attach secret-looking files?',
|
|
1543
|
+
`${secrets.map((s) => '@' + s).join(', ')} will be sent to the model in full.`,
|
|
1544
|
+
'Attach and send',
|
|
1545
|
+
() => this.deliver(text, session, continuation, true),
|
|
1546
|
+
);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
await this.deliver(text, session, continuation, false);
|
|
1550
|
+
}
|
|
1551
|
+
private async deliver(
|
|
1552
|
+
text: string,
|
|
1553
|
+
session: string,
|
|
1554
|
+
continuation: string | undefined,
|
|
1555
|
+
confirmSensitive: boolean,
|
|
1556
|
+
/** What was typed, when it differs from what is sent (a custom command). */
|
|
1557
|
+
typed = text,
|
|
1558
|
+
): Promise<void> {
|
|
1559
|
+
await this.client.submit(text, continuation, confirmSensitive);
|
|
1560
|
+
this.editor.addToHistory(typed);
|
|
1561
|
+
this.sent.add(typed);
|
|
1562
|
+
if (this.client.snapshot?.id === session && this.editor.getExpandedText() === typed) {
|
|
1043
1563
|
this.editor.setText('');
|
|
1044
1564
|
this.drafts.delete(session);
|
|
1045
1565
|
}
|
|
@@ -1050,16 +1570,143 @@ export class SalaamApp {
|
|
|
1050
1570
|
setTab(tab: ViewTab): void {
|
|
1051
1571
|
this.view.tab = tab;
|
|
1052
1572
|
this.resetScroll();
|
|
1053
|
-
if (tab === 'changes') void this.execute(() => this.
|
|
1573
|
+
if (tab === 'changes') void this.execute(() => this.loadChanges());
|
|
1574
|
+
this.tui.requestRender();
|
|
1575
|
+
}
|
|
1576
|
+
/** A local coding session reviews its folder against the review base; a coding workspace
|
|
1577
|
+
* keeps its guarded diff. */
|
|
1578
|
+
private loadChanges(): Promise<void> {
|
|
1579
|
+
return interactiveCoding(this.client.snapshot)
|
|
1580
|
+
? this.loadReview()
|
|
1581
|
+
: this.loadDiff(this.view.diffOffset);
|
|
1582
|
+
}
|
|
1583
|
+
private async loadReview(file = this.view.review?.file ?? 0): Promise<void> {
|
|
1584
|
+
const id = this.client.snapshot?.id;
|
|
1585
|
+
const data = await this.client.changeReview();
|
|
1586
|
+
if (this.client.snapshot?.id !== id) return;
|
|
1587
|
+
const index = data?.files.length ? Math.min(Math.max(0, file), data.files.length - 1) : 0;
|
|
1588
|
+
const path = data?.files[index]?.path;
|
|
1589
|
+
const hunks = data && path ? await this.client.changeHunks(data, path) : undefined;
|
|
1590
|
+
this.view.review = { data, file: index, hunks, hunk: -1 };
|
|
1591
|
+
this.document.invalidate();
|
|
1054
1592
|
this.tui.requestRender();
|
|
1055
1593
|
}
|
|
1594
|
+
/** Changes-tab keys: alt+left/right file, alt+up/down hunk; with an empty editor, a accepts,
|
|
1595
|
+
* r reverts and i starts a follow-up instruction about the selection. */
|
|
1596
|
+
private reviewKey(data: string): { consume: true } | undefined {
|
|
1597
|
+
const review = this.view.review;
|
|
1598
|
+
if (!review?.data?.files.length) return;
|
|
1599
|
+
const file = review.data.files[review.file]!;
|
|
1600
|
+
if (
|
|
1601
|
+
matchesKey(data, REVIEW_KEYS.nextFile.key) ||
|
|
1602
|
+
matchesKey(data, REVIEW_KEYS.previousFile.key)
|
|
1603
|
+
) {
|
|
1604
|
+
const next = review.file + (matchesKey(data, REVIEW_KEYS.nextFile.key) ? 1 : -1);
|
|
1605
|
+
if (next >= 0 && next < review.data.files.length)
|
|
1606
|
+
void this.execute(() => this.loadReview(next));
|
|
1607
|
+
return { consume: true };
|
|
1608
|
+
}
|
|
1609
|
+
if (
|
|
1610
|
+
matchesKey(data, REVIEW_KEYS.nextHunk.key) ||
|
|
1611
|
+
matchesKey(data, REVIEW_KEYS.previousHunk.key)
|
|
1612
|
+
) {
|
|
1613
|
+
const count = review.hunks?.length ?? 0;
|
|
1614
|
+
review.hunk = Math.max(
|
|
1615
|
+
-1,
|
|
1616
|
+
Math.min(count - 1, review.hunk + (matchesKey(data, REVIEW_KEYS.nextHunk.key) ? 1 : -1)),
|
|
1617
|
+
);
|
|
1618
|
+
this.document.invalidate();
|
|
1619
|
+
this.tui.requestRender();
|
|
1620
|
+
return { consume: true };
|
|
1621
|
+
}
|
|
1622
|
+
if (this.editor.getExpandedText() !== '') return;
|
|
1623
|
+
const hunk = review.hunk >= 0 ? review.hunks?.[review.hunk] : undefined;
|
|
1624
|
+
const what = file.path + (hunk ? ` (hunk ${review.hunk + 1})` : '');
|
|
1625
|
+
const act = (action: 'accept' | 'revert') => async () => {
|
|
1626
|
+
const data = await this.client.changeAct(review.data!, action, file.path, hunk);
|
|
1627
|
+
this.view.review = { data, file: review.file, hunk: -1 };
|
|
1628
|
+
await this.loadReview(review.file);
|
|
1629
|
+
this.client.report((action === 'accept' ? 'Accepted ' : 'Reverted ') + what + '.');
|
|
1630
|
+
};
|
|
1631
|
+
if (data === REVIEW_KEYS.accept.key) {
|
|
1632
|
+
void this.execute(act('accept'));
|
|
1633
|
+
return { consume: true };
|
|
1634
|
+
}
|
|
1635
|
+
if (data === REVIEW_KEYS.revert.key) {
|
|
1636
|
+
this.confirm(
|
|
1637
|
+
'Revert ' + what,
|
|
1638
|
+
(hunk ? 'Puts this hunk back' : 'Puts this file back') +
|
|
1639
|
+
' 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.',
|
|
1640
|
+
'Revert',
|
|
1641
|
+
act('revert'),
|
|
1642
|
+
);
|
|
1643
|
+
return { consume: true };
|
|
1644
|
+
}
|
|
1645
|
+
if (data === REVIEW_KEYS.instruct.key) {
|
|
1646
|
+
this.setTab('conversation');
|
|
1647
|
+
this.editor.setText(
|
|
1648
|
+
`In ${file.path}${hunk ? ` (the change at ${hunk.text.split('\n')[0]})` : ''}: `,
|
|
1649
|
+
);
|
|
1650
|
+
return { consume: true };
|
|
1651
|
+
}
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
/** /rewind [turn]: choose a turn and what to restore, review, then confirm once. */
|
|
1655
|
+
private async rewind(args: string): Promise<void> {
|
|
1656
|
+
const points = await this.client.checkpoints();
|
|
1657
|
+
if (!points.length)
|
|
1658
|
+
throw new Error(
|
|
1659
|
+
'No turn has been checkpointed in this session yet. A checkpoint is taken as each turn starts.',
|
|
1660
|
+
);
|
|
1661
|
+
const choose = (attempt: string) =>
|
|
1662
|
+
this.choice(
|
|
1663
|
+
'Rewind · what to restore',
|
|
1664
|
+
[
|
|
1665
|
+
{
|
|
1666
|
+
value: 'both',
|
|
1667
|
+
label: 'Files and conversation',
|
|
1668
|
+
description: 'Restore files and continue from before this turn in a new session',
|
|
1669
|
+
},
|
|
1670
|
+
{
|
|
1671
|
+
value: 'files',
|
|
1672
|
+
label: 'Files only',
|
|
1673
|
+
description: 'Restore files; keep the conversation',
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
value: 'conversation',
|
|
1677
|
+
label: 'Conversation only',
|
|
1678
|
+
description: 'Continue from before this turn in a new session; files stay',
|
|
1679
|
+
},
|
|
1680
|
+
],
|
|
1681
|
+
(mode) =>
|
|
1682
|
+
void this.execute(async () => {
|
|
1683
|
+
const review = await this.client.rewindReview(attempt, mode as RewindMode);
|
|
1684
|
+
this.confirm(
|
|
1685
|
+
`Rewind to before turn ${review.turn}`,
|
|
1686
|
+
rewindSummary(review),
|
|
1687
|
+
'Rewind',
|
|
1688
|
+
async () => {
|
|
1689
|
+
const text = await this.client.rewind(review);
|
|
1690
|
+
if (review.mode !== 'files') this.editor.setText(text);
|
|
1691
|
+
},
|
|
1692
|
+
);
|
|
1693
|
+
}),
|
|
1694
|
+
);
|
|
1695
|
+
if (args) {
|
|
1696
|
+
const found = points.find((p) => String(p.turn) === args.trim());
|
|
1697
|
+
if (!found) throw new Error(`No checkpoint for turn ${args.trim()}. /rewind lists them.`);
|
|
1698
|
+
choose(found.attempt_id);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
this.choice('Rewind to before which turn?', rewindItems(points), choose);
|
|
1702
|
+
}
|
|
1056
1703
|
private resetScroll(): void {
|
|
1057
1704
|
if (this.view.tab === 'conversation' || this.view.tab === 'commands') this.scroll.scrollToEnd();
|
|
1058
1705
|
else this.scroll.scrollTo(0, { disableFollow: true });
|
|
1059
1706
|
}
|
|
1060
1707
|
private async refresh(): Promise<void> {
|
|
1061
1708
|
await this.client.refresh();
|
|
1062
|
-
if (this.view.tab === 'changes') await this.
|
|
1709
|
+
if (this.view.tab === 'changes') await this.loadChanges();
|
|
1063
1710
|
}
|
|
1064
1711
|
private async loadDiff(offset: number): Promise<void> {
|
|
1065
1712
|
if (this.diffBusy) return;
|
|
@@ -1099,7 +1746,18 @@ export class SalaamApp {
|
|
|
1099
1746
|
void this.execute(async () => {
|
|
1100
1747
|
const approval = approvals.find((a) => a.id === id)!;
|
|
1101
1748
|
const reviewed = await this.client.review(approval);
|
|
1102
|
-
|
|
1749
|
+
// Desk sessions can approve for the session or always; strict work only ever approves once.
|
|
1750
|
+
const rules = await this.client.approvalRules().catch(() => ({ applies: false }));
|
|
1751
|
+
const onceOnly = rules.applies
|
|
1752
|
+
? onceOnlyReason(String(reviewed.operation.value))
|
|
1753
|
+
: undefined;
|
|
1754
|
+
this.showApproval(
|
|
1755
|
+
reviewed.approval,
|
|
1756
|
+
reviewed.operation,
|
|
1757
|
+
rules.applies && !onceOnly,
|
|
1758
|
+
onceOnly,
|
|
1759
|
+
rules.applies,
|
|
1760
|
+
);
|
|
1103
1761
|
});
|
|
1104
1762
|
if (approvals.length === 1) open(approvals[0]!.id);
|
|
1105
1763
|
else
|
|
@@ -1115,7 +1773,103 @@ export class SalaamApp {
|
|
|
1115
1773
|
open,
|
|
1116
1774
|
);
|
|
1117
1775
|
}
|
|
1118
|
-
|
|
1776
|
+
/** /permissions → Approval rules: list them, remove one, or add one in plain words. */
|
|
1777
|
+
private async approvalRules(): Promise<void> {
|
|
1778
|
+
const { applies, rules, problems } = await this.client.approvalRules();
|
|
1779
|
+
if (!applies) {
|
|
1780
|
+
this.client.report('Approval rules apply only to desk coding sessions.');
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
const describe = (r: import('./approval-rules.js').StoredRule) =>
|
|
1784
|
+
`${r.effect} ${r.tool} ${r.command ?? (r.paths ?? []).join(', ')}`.trim();
|
|
1785
|
+
this.choice(
|
|
1786
|
+
'Approval rules · deny beats ask beats allow · unmatched requests ask',
|
|
1787
|
+
[
|
|
1788
|
+
{
|
|
1789
|
+
value: 'add',
|
|
1790
|
+
label: 'Add a rule',
|
|
1791
|
+
description: 'e.g. allow command npm test * · deny edit src/secrets/**',
|
|
1792
|
+
},
|
|
1793
|
+
...rules.map((r) => ({
|
|
1794
|
+
value: 'rule:' + r.id,
|
|
1795
|
+
label: describe(r),
|
|
1796
|
+
description:
|
|
1797
|
+
r.source === 'user'
|
|
1798
|
+
? 'From your permissions.json; edit that file to change it'
|
|
1799
|
+
: r.source === 'repo'
|
|
1800
|
+
? 'Always, in this repo · choose to remove'
|
|
1801
|
+
: 'This session · choose to remove',
|
|
1802
|
+
})),
|
|
1803
|
+
...problems.map((p, i) => ({ value: 'problem:' + i, label: 'Problem', description: p })),
|
|
1804
|
+
],
|
|
1805
|
+
(value) => {
|
|
1806
|
+
if (value === 'add') {
|
|
1807
|
+
this.taskInput(
|
|
1808
|
+
'New approval rule',
|
|
1809
|
+
'',
|
|
1810
|
+
'effect tool words-or-paths',
|
|
1811
|
+
'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',
|
|
1812
|
+
(text) => {
|
|
1813
|
+
const [effect, tool, ...rest] = text.trim().split(/\s+/);
|
|
1814
|
+
const target = rest.join(' ');
|
|
1815
|
+
const rule =
|
|
1816
|
+
tool === 'edit'
|
|
1817
|
+
? {
|
|
1818
|
+
effect,
|
|
1819
|
+
tool,
|
|
1820
|
+
paths: target
|
|
1821
|
+
.split(',')
|
|
1822
|
+
.map((p) => p.trim())
|
|
1823
|
+
.filter(Boolean),
|
|
1824
|
+
}
|
|
1825
|
+
: { effect, tool, command: target };
|
|
1826
|
+
this.choice(
|
|
1827
|
+
'Keep this rule for…',
|
|
1828
|
+
[
|
|
1829
|
+
{
|
|
1830
|
+
value: 'session',
|
|
1831
|
+
label: 'This session',
|
|
1832
|
+
description: 'Gone when the session ends',
|
|
1833
|
+
},
|
|
1834
|
+
{
|
|
1835
|
+
value: 'repo',
|
|
1836
|
+
label: 'This repo',
|
|
1837
|
+
description: "Every desk session here; kept in Salaam's state",
|
|
1838
|
+
},
|
|
1839
|
+
],
|
|
1840
|
+
(scope) =>
|
|
1841
|
+
void this.execute(() =>
|
|
1842
|
+
this.client.addRule(
|
|
1843
|
+
scope as 'session' | 'repo',
|
|
1844
|
+
rule as import('./approval-rules.js').ApprovalRule,
|
|
1845
|
+
),
|
|
1846
|
+
),
|
|
1847
|
+
);
|
|
1848
|
+
},
|
|
1849
|
+
);
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
if (!value.startsWith('rule:')) return;
|
|
1853
|
+
const rule = rules.find((r) => 'rule:' + r.id === value)!;
|
|
1854
|
+
if (rule.source === 'user') {
|
|
1855
|
+
this.client.report(
|
|
1856
|
+
'This rule comes from your permissions.json; edit that file to change it.',
|
|
1857
|
+
);
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
this.confirm('Remove this rule?', describe(rule), 'remove', () =>
|
|
1861
|
+
this.client.removeRule(rule.id),
|
|
1862
|
+
);
|
|
1863
|
+
},
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
private showApproval(
|
|
1867
|
+
approval: Approval,
|
|
1868
|
+
operation: import('./protocol.js').Operation,
|
|
1869
|
+
offerRules = false,
|
|
1870
|
+
onceOnly?: string,
|
|
1871
|
+
desk = false,
|
|
1872
|
+
): void {
|
|
1119
1873
|
this.showModal(
|
|
1120
1874
|
new ApprovalModal(
|
|
1121
1875
|
approval,
|
|
@@ -1127,8 +1881,72 @@ export class SalaamApp {
|
|
|
1127
1881
|
() => this.closeModal(),
|
|
1128
1882
|
(decision) => {
|
|
1129
1883
|
this.closeModal();
|
|
1130
|
-
|
|
1884
|
+
if (decision === 'approve' || decision === 'deny') {
|
|
1885
|
+
void this.execute(() => this.client.decide(approval, decision));
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
if (decision === 'deny-feedback') {
|
|
1889
|
+
// Deny, then tell the agent why in its own turn, the way steering reaches it.
|
|
1890
|
+
this.taskInput(
|
|
1891
|
+
'Deny and tell it why',
|
|
1892
|
+
'',
|
|
1893
|
+
'Why not, or what to do instead',
|
|
1894
|
+
'One line. The request is denied and this reaches the agent as feedback.',
|
|
1895
|
+
(why) =>
|
|
1896
|
+
void this.execute(async () => {
|
|
1897
|
+
await this.client.decide(approval, 'deny');
|
|
1898
|
+
const text = why.trim();
|
|
1899
|
+
if (!text) return;
|
|
1900
|
+
// The turn has usually moved on by now; if it ended, put the reason in the
|
|
1901
|
+
// composer instead of losing it.
|
|
1902
|
+
const feedback = `I denied that request (${safeText(operation.target)}). ${text}`;
|
|
1903
|
+
try {
|
|
1904
|
+
await this.client.steer(feedback);
|
|
1905
|
+
} catch {
|
|
1906
|
+
this.editor.setText(feedback);
|
|
1907
|
+
this.client.report(
|
|
1908
|
+
'Denied. The turn had ended, so your reason is in the composer; press enter to send it.',
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
}),
|
|
1912
|
+
);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
// The rule is the exact request just reviewed: its command words or its edited paths.
|
|
1916
|
+
// If it cannot become a rule (shell features, odd words), nothing is approved; say why.
|
|
1917
|
+
const approveWith = (scope: 'session' | 'repo') => async () => {
|
|
1918
|
+
// While the approval is pending the status line shows it, not a notice, so a
|
|
1919
|
+
// refused rule is shown where it cannot be missed.
|
|
1920
|
+
try {
|
|
1921
|
+
await this.client.ruleFromOperation(operation.id, scope);
|
|
1922
|
+
} catch (error) {
|
|
1923
|
+
this.showText(
|
|
1924
|
+
'Not saved as a rule',
|
|
1925
|
+
`${error instanceof Error ? error.message : String(error)}\n\nNothing was approved. Press ctrl+o to approve it once instead.`,
|
|
1926
|
+
);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
await this.client.decide(approval, 'approve');
|
|
1930
|
+
this.client.report(
|
|
1931
|
+
scope === 'session'
|
|
1932
|
+
? 'Approved, and the same request will be approved for the rest of this session.'
|
|
1933
|
+
: 'Approved, and the same request will always be approved in this repo. Remove it in /permissions.',
|
|
1934
|
+
);
|
|
1935
|
+
};
|
|
1936
|
+
if (decision === 'approve-session') void this.execute(approveWith('session'));
|
|
1937
|
+
else
|
|
1938
|
+
this.confirm(
|
|
1939
|
+
'Always allow in this repo?',
|
|
1940
|
+
`${safeText(operation.target)}
|
|
1941
|
+
|
|
1942
|
+
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.`,
|
|
1943
|
+
'always allow',
|
|
1944
|
+
approveWith('repo'),
|
|
1945
|
+
);
|
|
1131
1946
|
},
|
|
1947
|
+
offerRules,
|
|
1948
|
+
onceOnly,
|
|
1949
|
+
{ desk, reason: approvalReason(String(operation.value), this.view.mode?.mode) },
|
|
1132
1950
|
),
|
|
1133
1951
|
86,
|
|
1134
1952
|
);
|
|
@@ -1181,10 +1999,15 @@ export class SalaamApp {
|
|
|
1181
1999
|
},
|
|
1182
2000
|
...(current.coding
|
|
1183
2001
|
? [
|
|
2002
|
+
{
|
|
2003
|
+
value: 'rules',
|
|
2004
|
+
label: 'Approval rules',
|
|
2005
|
+
description: 'Allow, ask or deny by command words and path globs',
|
|
2006
|
+
},
|
|
1184
2007
|
{
|
|
1185
2008
|
value: 'review',
|
|
1186
2009
|
label: 'Review every mutation',
|
|
1187
|
-
description: 'Explicit approval for each edit and
|
|
2010
|
+
description: 'Explicit approval for each edit and shell command',
|
|
1188
2011
|
},
|
|
1189
2012
|
{
|
|
1190
2013
|
value: 'scoped-edit',
|
|
@@ -1195,6 +2018,10 @@ export class SalaamApp {
|
|
|
1195
2018
|
: []),
|
|
1196
2019
|
],
|
|
1197
2020
|
(mode) => {
|
|
2021
|
+
if (mode === 'rules') {
|
|
2022
|
+
void this.execute(() => this.approvalRules());
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
1198
2025
|
if (mode === 'inspect-current') {
|
|
1199
2026
|
this.showText('Permissions and execution boundary', JSON.stringify(current, null, 2));
|
|
1200
2027
|
return;
|
|
@@ -1203,7 +2030,7 @@ export class SalaamApp {
|
|
|
1203
2030
|
this.showModal(
|
|
1204
2031
|
new TextModal(
|
|
1205
2032
|
'Review permission policy',
|
|
1206
|
-
`Session ${session}\nWorkspace ${current.policy.root}\nMode ${mode}\nAuto-edit paths: ${paths.join(', ') || '(none)'}\
|
|
2033
|
+
`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.`,
|
|
1207
2034
|
this.view.theme,
|
|
1208
2035
|
() => this.terminal.rows,
|
|
1209
2036
|
() => this.tui.requestRender(),
|
|
@@ -1832,7 +2659,11 @@ export class SalaamApp {
|
|
|
1832
2659
|
label: 'Operator inbox',
|
|
1833
2660
|
description: 'Shared durable attention and results',
|
|
1834
2661
|
},
|
|
1835
|
-
{
|
|
2662
|
+
{
|
|
2663
|
+
value: 'model',
|
|
2664
|
+
label: 'Choose model and provider',
|
|
2665
|
+
description: '/model · switches this session',
|
|
2666
|
+
},
|
|
1836
2667
|
{ value: 'help', label: 'Slash commands', description: '/help' },
|
|
1837
2668
|
{ value: 'new', label: 'New session', description: 'ctrl+n' },
|
|
1838
2669
|
{
|
|
@@ -1852,8 +2683,13 @@ export class SalaamApp {
|
|
|
1852
2683
|
label: 'Job actions',
|
|
1853
2684
|
description: 'Bound one-shot admission and durable occurrence recovery',
|
|
1854
2685
|
},
|
|
1855
|
-
{ value: 'changes', label: 'Review changes', description: '
|
|
2686
|
+
{ value: 'changes', label: 'Review changes', description: 'alt+g · guarded workspace diff' },
|
|
1856
2687
|
{ value: 'commands', label: 'Commands and output', description: 'ctrl+t' },
|
|
2688
|
+
{
|
|
2689
|
+
value: 'transcript',
|
|
2690
|
+
label: 'Full transcript',
|
|
2691
|
+
description: 'ctrl+x · every tool call and its output',
|
|
2692
|
+
},
|
|
1857
2693
|
{
|
|
1858
2694
|
value: 'context',
|
|
1859
2695
|
label: 'Session context',
|
|
@@ -1905,11 +2741,11 @@ export class SalaamApp {
|
|
|
1905
2741
|
label: 'Cancel current work',
|
|
1906
2742
|
description: 'Explicitly cancel a running instruction',
|
|
1907
2743
|
},
|
|
1908
|
-
{ value: 'refresh', label: 'Refresh connection', description: '
|
|
2744
|
+
{ value: 'refresh', label: 'Refresh connection', description: 'f5' },
|
|
1909
2745
|
{
|
|
1910
2746
|
value: 'editor',
|
|
1911
2747
|
label: 'Edit prompt externally',
|
|
1912
|
-
description: 'ctrl+
|
|
2748
|
+
description: 'ctrl+g · VISUAL or EDITOR',
|
|
1913
2749
|
},
|
|
1914
2750
|
{
|
|
1915
2751
|
value: 'theme',
|
|
@@ -2163,46 +2999,18 @@ export class SalaamApp {
|
|
|
2163
2999
|
},
|
|
2164
3000
|
);
|
|
2165
3001
|
}
|
|
3002
|
+
/** The keys for where you are: `?` on an empty composer, or F1. Built from tui-keymap.ts. */
|
|
2166
3003
|
private help(): void {
|
|
3004
|
+
const snapshot = this.client.snapshot;
|
|
3005
|
+
const sheet = keySheet({
|
|
3006
|
+
tab: this.view.tab,
|
|
3007
|
+
coding: interactiveCoding(snapshot),
|
|
3008
|
+
running: interactiveCoding(snapshot) && runningAttempt(snapshot) !== undefined,
|
|
3009
|
+
custom: this.customCommands(),
|
|
3010
|
+
});
|
|
2167
3011
|
this.choice(
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
{
|
|
2171
|
-
value: 'close',
|
|
2172
|
-
label: 'Enter · send the exact prompt',
|
|
2173
|
-
description: 'The durable outbox saves it before delivery',
|
|
2174
|
-
},
|
|
2175
|
-
{
|
|
2176
|
-
value: 'close',
|
|
2177
|
-
label: 'Alt+Enter / Ctrl+J · new line',
|
|
2178
|
-
description: 'Shift+Enter also works with supported keyboard protocols',
|
|
2179
|
-
},
|
|
2180
|
-
{
|
|
2181
|
-
value: 'close',
|
|
2182
|
-
label: 'Ctrl+K · all actions',
|
|
2183
|
-
description: 'Ctrl+P sessions · Ctrl+N new session · Ctrl+O approvals',
|
|
2184
|
-
},
|
|
2185
|
-
{
|
|
2186
|
-
value: 'close',
|
|
2187
|
-
label: 'Tab · switch view',
|
|
2188
|
-
description: 'Conversation · Changes · Commands · Context',
|
|
2189
|
-
},
|
|
2190
|
-
{
|
|
2191
|
-
value: 'close',
|
|
2192
|
-
label: 'PageUp / PageDown · scroll',
|
|
2193
|
-
description: 'Mouse wheel also scrolls; Ctrl+Home/End jump to the top/bottom',
|
|
2194
|
-
},
|
|
2195
|
-
{
|
|
2196
|
-
value: 'close',
|
|
2197
|
-
label: 'Ctrl+E · external prompt editor',
|
|
2198
|
-
description: 'Runs VISUAL or EDITOR with terminal modes restored',
|
|
2199
|
-
},
|
|
2200
|
-
{
|
|
2201
|
-
value: 'close',
|
|
2202
|
-
label: 'Ctrl+C · detach',
|
|
2203
|
-
description: 'Work continues. Cancel and stop service are separate actions.',
|
|
2204
|
-
},
|
|
2205
|
-
],
|
|
3012
|
+
sheet.title,
|
|
3013
|
+
sheet.rows.map((row) => ({ value: 'close', ...row })),
|
|
2206
3014
|
() => {},
|
|
2207
3015
|
);
|
|
2208
3016
|
}
|
|
@@ -2244,8 +3052,77 @@ export class SalaamApp {
|
|
|
2244
3052
|
}
|
|
2245
3053
|
}
|
|
2246
3054
|
}
|
|
3055
|
+
/** Ctrl+C: stop a running coding turn, else clear the composer, else arm; twice within 2 s leaves. */
|
|
3056
|
+
private ctrlC(): void {
|
|
3057
|
+
const attempt = interactiveCoding(this.client.snapshot)
|
|
3058
|
+
? runningAttempt(this.client.snapshot)
|
|
3059
|
+
: undefined;
|
|
3060
|
+
const action = this.modal
|
|
3061
|
+
? 'exit'
|
|
3062
|
+
: ctrlCAction({
|
|
3063
|
+
running: attempt !== undefined,
|
|
3064
|
+
text: this.editor.getExpandedText(),
|
|
3065
|
+
armedAt: this.ctrlCArmed,
|
|
3066
|
+
now: Date.now(),
|
|
3067
|
+
});
|
|
3068
|
+
this.ctrlCArmed = undefined;
|
|
3069
|
+
if (action === 'exit') this.finish();
|
|
3070
|
+
else if (action === 'interrupt')
|
|
3071
|
+
void this.execute(async () => {
|
|
3072
|
+
await this.client.cancel(attempt!);
|
|
3073
|
+
this.client.report('Stopped the turn. Press ctrl+c twice to leave.');
|
|
3074
|
+
});
|
|
3075
|
+
else if (action === 'clear') {
|
|
3076
|
+
this.editor.setText('');
|
|
3077
|
+
this.client.report('Cleared. Press ctrl+c again to leave, or ctrl+- to undo.');
|
|
3078
|
+
} else {
|
|
3079
|
+
this.ctrlCArmed = Date.now();
|
|
3080
|
+
this.client.report('Press ctrl+c again to leave. Work continues in the service.');
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
private startSearch(): void {
|
|
3084
|
+
this.search = { query: '', index: this.sent.entries.length, draft: this.editor.getText() };
|
|
3085
|
+
this.client.report(searchPrompt('', undefined));
|
|
3086
|
+
}
|
|
3087
|
+
/** Keys while Ctrl+R search is open: type to narrow, ctrl+r for older, enter keeps, esc restores. */
|
|
3088
|
+
private searchKey(data: string): { consume: true } {
|
|
3089
|
+
const s = this.search!,
|
|
3090
|
+
entries = this.sent.entries;
|
|
3091
|
+
const show = () => {
|
|
3092
|
+
const match = s.index < entries.length ? entries[s.index] : undefined;
|
|
3093
|
+
if (match !== undefined) this.editor.setText(match);
|
|
3094
|
+
this.client.report(searchPrompt(s.query, match));
|
|
3095
|
+
};
|
|
3096
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+g')) {
|
|
3097
|
+
this.editor.setText(s.draft);
|
|
3098
|
+
this.search = undefined;
|
|
3099
|
+
this.client.report('');
|
|
3100
|
+
} else if (matchesKey(data, 'enter') || matchesKey(data, 'right') || matchesKey(data, 'left')) {
|
|
3101
|
+
this.search = undefined;
|
|
3102
|
+
this.client.report('');
|
|
3103
|
+
} else if (matchesKey(data, 'ctrl+r')) {
|
|
3104
|
+
const found = searchHistory(entries, s.query, s.index);
|
|
3105
|
+
if (found >= 0) s.index = found;
|
|
3106
|
+
show();
|
|
3107
|
+
} else if (matchesKey(data, 'backspace')) {
|
|
3108
|
+
s.query = s.query.slice(0, -1);
|
|
3109
|
+
s.index = searchHistory(entries, s.query, entries.length);
|
|
3110
|
+
if (s.index < 0) s.index = entries.length;
|
|
3111
|
+
if (s.query === '') this.editor.setText(s.draft);
|
|
3112
|
+
show();
|
|
3113
|
+
} else if (data.length === 1 && data >= ' ' && data !== '\x7f') {
|
|
3114
|
+
s.query += data;
|
|
3115
|
+
const found = searchHistory(entries, s.query, Math.min(s.index + 1, entries.length));
|
|
3116
|
+
s.index = found >= 0 ? found : entries.length;
|
|
3117
|
+
if (found < 0) this.editor.setText(s.draft);
|
|
3118
|
+
show();
|
|
3119
|
+
}
|
|
3120
|
+
this.tui.requestRender();
|
|
3121
|
+
return { consume: true };
|
|
3122
|
+
}
|
|
2247
3123
|
private async initialize(): Promise<void> {
|
|
2248
3124
|
await this.execute(async () => {
|
|
3125
|
+
if (this.options.start) return this.openHere(this.options.start);
|
|
2249
3126
|
await this.client.list();
|
|
2250
3127
|
if (this.options.session)
|
|
2251
3128
|
await this.client.open(this.options.session, {
|
|
@@ -2259,6 +3136,34 @@ export class SalaamApp {
|
|
|
2259
3136
|
}
|
|
2260
3137
|
});
|
|
2261
3138
|
}
|
|
3139
|
+
/** One command for this folder: start the service if needed, then continue the folder's
|
|
3140
|
+
* latest session, start a new coding session in it, or show the session list. */
|
|
3141
|
+
private async openHere(start: import('./session-pick.js').CodeStart): Promise<void> {
|
|
3142
|
+
const { latestSessionFor, deskUsesAnthropic } = await import('./session-pick.js');
|
|
3143
|
+
await this.startService();
|
|
3144
|
+
if (
|
|
3145
|
+
deskUsesAnthropic(
|
|
3146
|
+
!!this.selectedRoute,
|
|
3147
|
+
this.client.health?.provider_route.provider,
|
|
3148
|
+
existsSync(credentialPath()) || existsSync(credentialPath() + '.json'),
|
|
3149
|
+
)
|
|
3150
|
+
)
|
|
3151
|
+
this.selectedRoute = providerRoute('anthropic');
|
|
3152
|
+
if (start === 'resume') return this.sessions();
|
|
3153
|
+
const cwd = resolve(this.options.cwd ?? process.cwd());
|
|
3154
|
+
const existing = start === 'continue' ? latestSessionFor(this.client.sessions, cwd) : undefined;
|
|
3155
|
+
if (existing)
|
|
3156
|
+
await this.client.open(existing, {
|
|
3157
|
+
observe: this.options.observe,
|
|
3158
|
+
takeover: this.options.takeover,
|
|
3159
|
+
});
|
|
3160
|
+
else {
|
|
3161
|
+
await this.client.create(cwd, false, this.selectedRoute, true);
|
|
3162
|
+
if (start === 'continue')
|
|
3163
|
+
this.client.notice = 'No earlier session in this folder, so this is a new one.';
|
|
3164
|
+
}
|
|
3165
|
+
this.setTab('conversation');
|
|
3166
|
+
}
|
|
2262
3167
|
async run(): Promise<void> {
|
|
2263
3168
|
this.running = true;
|
|
2264
3169
|
const close = () => {
|
|
@@ -2278,7 +3183,11 @@ export class SalaamApp {
|
|
|
2278
3183
|
if (
|
|
2279
3184
|
this.client.preview ||
|
|
2280
3185
|
this.client.snapshot?.approvals.some((a) => a.state === 'pending') ||
|
|
2281
|
-
this.client.commands.some((run) => !commandTerminal(run.state))
|
|
3186
|
+
this.client.commands.some((run) => !commandTerminal(run.state)) ||
|
|
3187
|
+
// The progress line counts seconds while a turn runs, so it never shows a stale state.
|
|
3188
|
+
this.client.snapshot?.attempts.some((a) =>
|
|
3189
|
+
['accepted', 'dispatching', 'running'].includes(a.state),
|
|
3190
|
+
)
|
|
2282
3191
|
)
|
|
2283
3192
|
this.tui.requestRender();
|
|
2284
3193
|
}, 1000);
|
|
@@ -2293,6 +3202,7 @@ export class SalaamApp {
|
|
|
2293
3202
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.off(signal, close);
|
|
2294
3203
|
process.stdin.off('end', close);
|
|
2295
3204
|
process.stdout.off('error', close);
|
|
3205
|
+
if (this.statusOn) this.herdr.release();
|
|
2296
3206
|
await this.terminal.drainInput(150, 25);
|
|
2297
3207
|
process.stdin.pause();
|
|
2298
3208
|
process.stdin.unref();
|