@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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export const GLOBAL_KEYS = [
|
|
2
|
+
{ key: 'ctrl+k', action: 'palette', label: 'all actions' },
|
|
3
|
+
{ key: 'f2', action: 'palette', label: 'all actions' },
|
|
4
|
+
{ key: 'ctrl+p', action: 'sessions', label: 'sessions' },
|
|
5
|
+
{ key: 'ctrl+n', action: 'new', label: 'new session' },
|
|
6
|
+
{ key: 'ctrl+o', action: 'approvals', label: 'approvals' },
|
|
7
|
+
{ key: 'alt+g', action: 'changes', label: 'changes' },
|
|
8
|
+
{ key: 'ctrl+t', action: 'commands', label: 'commands' },
|
|
9
|
+
{ key: 'ctrl+b', action: 'rail', label: 'show or hide the side panel' },
|
|
10
|
+
{ key: 'ctrl+x', action: 'transcript', label: 'full transcript on or off' },
|
|
11
|
+
{ key: 'f5', action: 'refresh', label: 'refresh' },
|
|
12
|
+
{ key: 'ctrl+g', action: 'editor', label: 'external prompt editor' },
|
|
13
|
+
{ key: 'shift+tab', action: 'mode', label: 'next mode' },
|
|
14
|
+
{ key: 'f1', action: 'help', label: 'these keys' },
|
|
15
|
+
];
|
|
16
|
+
/** The per-file review on the Changes tab of a desk session. */
|
|
17
|
+
export const REVIEW_KEYS = {
|
|
18
|
+
nextFile: { key: 'alt+right', label: 'next file' },
|
|
19
|
+
previousFile: { key: 'alt+left', label: 'previous file' },
|
|
20
|
+
nextHunk: { key: 'alt+down', label: 'next hunk' },
|
|
21
|
+
previousHunk: { key: 'alt+up', label: 'previous hunk' },
|
|
22
|
+
accept: { key: 'a', label: 'accept the file or hunk' },
|
|
23
|
+
revert: { key: 'r', label: 'revert it (asks first)' },
|
|
24
|
+
instruct: { key: 'i', label: 'tell the model about it' },
|
|
25
|
+
};
|
|
26
|
+
/** Keys while a desk turn runs (tui-turn-keys.ts decides them). */
|
|
27
|
+
export const TURN_KEYS = [
|
|
28
|
+
{ key: 'escape', label: 'stop the turn' },
|
|
29
|
+
{ key: 'enter', label: 'steer it' },
|
|
30
|
+
{ key: 'tab', label: 'queue for after' },
|
|
31
|
+
];
|
|
32
|
+
const COMPOSER = [
|
|
33
|
+
{ label: 'Enter · send', description: 'The durable outbox saves it before delivery' },
|
|
34
|
+
{ label: 'Alt+Enter / Ctrl+J · new line' },
|
|
35
|
+
{ label: 'Tab · switch view', description: 'Conversation · Changes · Commands · Context' },
|
|
36
|
+
{ label: 'PageUp / PageDown · scroll', description: 'Ctrl+Home/End jump to the top/bottom' },
|
|
37
|
+
{ label: '@ · attach a file or image' },
|
|
38
|
+
{ label: '/ · slash commands' },
|
|
39
|
+
{
|
|
40
|
+
label: 'Ctrl+R · search sent messages',
|
|
41
|
+
description: 'Ctrl+R for older, Enter keeps, Esc restores',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: 'Ctrl+A/E · Ctrl+U/K/W · Ctrl+Y · Ctrl+-',
|
|
45
|
+
description: 'Line start/end, kill to start/end/word, yank, undo; Alt+B/F words',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
label: '!command · run a shell command',
|
|
49
|
+
description: 'Same approval, sandbox and danger check',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: 'Ctrl+C · stop, clear, then leave',
|
|
53
|
+
description: 'Stops a running turn, else clears text; twice on empty leaves. Work continues.',
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
const CODING_COMMANDS = [
|
|
57
|
+
{ label: '/rewind · go back to an earlier turn' },
|
|
58
|
+
{ label: '/jobs · background commands' },
|
|
59
|
+
{ label: '/undo · revert the last edit' },
|
|
60
|
+
];
|
|
61
|
+
export function keyLabel(key) {
|
|
62
|
+
const names = { escape: 'Esc', enter: 'Enter', tab: 'Tab' };
|
|
63
|
+
if (names[key])
|
|
64
|
+
return names[key];
|
|
65
|
+
if (key.length === 1)
|
|
66
|
+
return key;
|
|
67
|
+
return key
|
|
68
|
+
.split('+')
|
|
69
|
+
.map((part) => part === 'right'
|
|
70
|
+
? '→'
|
|
71
|
+
: part === 'left'
|
|
72
|
+
? '←'
|
|
73
|
+
: part === 'up'
|
|
74
|
+
? '↑'
|
|
75
|
+
: part === 'down'
|
|
76
|
+
? '↓'
|
|
77
|
+
: part.length <= 2
|
|
78
|
+
? part.toUpperCase()
|
|
79
|
+
: part[0].toUpperCase() + part.slice(1))
|
|
80
|
+
.join('+');
|
|
81
|
+
}
|
|
82
|
+
export function keySheet(c) {
|
|
83
|
+
const global = GLOBAL_KEYS.map((k) => ({ label: keyLabel(k.key) + ' · ' + k.label }));
|
|
84
|
+
const approvals = { label: 'Approvals show their own keys along the bottom' };
|
|
85
|
+
if (c.tab === 'changes' && c.coding)
|
|
86
|
+
return {
|
|
87
|
+
title: 'Keys · Review changes',
|
|
88
|
+
rows: [
|
|
89
|
+
...Object.values(REVIEW_KEYS).map((k) => ({ label: keyLabel(k.key) + ' · ' + k.label })),
|
|
90
|
+
{ label: 'a, r and i work while the composer is empty' },
|
|
91
|
+
...global,
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
if (c.coding && c.running)
|
|
95
|
+
return {
|
|
96
|
+
title: 'Keys · Turn running',
|
|
97
|
+
rows: [
|
|
98
|
+
...TURN_KEYS.map((k) => ({ label: keyLabel(k.key) + ' · ' + k.label })),
|
|
99
|
+
...global,
|
|
100
|
+
approvals,
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
title: 'Keys · Conversation',
|
|
105
|
+
rows: [
|
|
106
|
+
...COMPOSER,
|
|
107
|
+
...(c.coding ? CODING_COMMANDS : []),
|
|
108
|
+
...(c.custom ?? []).map((k) => ({
|
|
109
|
+
label: `/${k.name}${k.argumentHint ? ' ' + k.argumentHint : ''} · your command`,
|
|
110
|
+
description: k.description,
|
|
111
|
+
})),
|
|
112
|
+
...global,
|
|
113
|
+
approvals,
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CombinedAutocompleteProvider } from '@earendil-works/pi-tui';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { listWorkspaceFiles, rankFiles } from './mentions.js';
|
|
4
|
+
/**
|
|
5
|
+
* `@` completes workspace files from git's own listing (ignored files stay out) instead of
|
|
6
|
+
* pi-tui's `fd` walk, which is absent on most desks. The listing is cached for 5 s.
|
|
7
|
+
*/
|
|
8
|
+
export class MentionAutocompleteProvider extends CombinedAutocompleteProvider {
|
|
9
|
+
root;
|
|
10
|
+
cache;
|
|
11
|
+
constructor(commands, root) {
|
|
12
|
+
super(commands, root, null);
|
|
13
|
+
this.root = root;
|
|
14
|
+
}
|
|
15
|
+
files() {
|
|
16
|
+
if (!this.cache || Date.now() - this.cache.at > 5000)
|
|
17
|
+
this.cache = { at: Date.now(), files: listWorkspaceFiles(this.root) };
|
|
18
|
+
return this.cache.files;
|
|
19
|
+
}
|
|
20
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
21
|
+
const before = (lines[cursorLine] ?? '').slice(0, cursorCol);
|
|
22
|
+
const match = /(?:^|[\s([{])(@"[^"]*|@[^\s"]*)$/.exec(before);
|
|
23
|
+
if (!match)
|
|
24
|
+
return super.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
25
|
+
if (options.signal.aborted)
|
|
26
|
+
return null;
|
|
27
|
+
const prefix = match[1];
|
|
28
|
+
const quoted = prefix.startsWith('@"');
|
|
29
|
+
const items = rankFiles(this.files(), prefix.slice(quoted ? 2 : 1))
|
|
30
|
+
.slice(0, 20)
|
|
31
|
+
.map((file) => ({
|
|
32
|
+
value: quoted || /\s/.test(file) ? `@"${file}"` : `@${file}`,
|
|
33
|
+
label: basename(file),
|
|
34
|
+
description: file,
|
|
35
|
+
}));
|
|
36
|
+
return items.length ? { items, prefix } : null;
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/src/tui-modals.js
CHANGED
|
@@ -132,11 +132,12 @@ export class FormModal {
|
|
|
132
132
|
this.theme.ink(this.labels?.field ?? 'Project directory', 'muted'),
|
|
133
133
|
...this.input.render(Math.max(1, width - 4)).map((line) => this.theme.ink(line)),
|
|
134
134
|
'',
|
|
135
|
-
|
|
135
|
+
// A long hint (the YOLO warning) wraps instead of running past the frame.
|
|
136
|
+
...wrapTextWithAnsi(this.theme.ink(this.allowCoding
|
|
136
137
|
? this.coding
|
|
137
138
|
? '[✓] Coding workspace · guarded tools'
|
|
138
139
|
: '[ ] Conversation session'
|
|
139
|
-
: (this.labels?.hint ?? 'Conversation session · coding service is off'), this.coding ? 'jade' : 'muted'),
|
|
140
|
+
: (this.labels?.hint ?? 'Conversation session · coding service is off'), this.coding ? 'jade' : 'muted'), Math.max(1, width - 4)),
|
|
140
141
|
'',
|
|
141
142
|
this.theme.key('enter', this.labels?.submit ?? 'create') +
|
|
142
143
|
' ' +
|
|
@@ -168,7 +169,9 @@ export class ConfirmModal {
|
|
|
168
169
|
confirm;
|
|
169
170
|
focused = false;
|
|
170
171
|
selected = false;
|
|
171
|
-
constructor(title, description, confirmLabel, theme, renderAgain, close, confirm
|
|
172
|
+
constructor(title, description, confirmLabel, theme, renderAgain, close, confirm,
|
|
173
|
+
/** Start on the action rather than on "Keep current state" (cancel a running turn). */
|
|
174
|
+
confirmByDefault = false) {
|
|
172
175
|
this.title = title;
|
|
173
176
|
this.description = description;
|
|
174
177
|
this.confirmLabel = confirmLabel;
|
|
@@ -176,6 +179,7 @@ export class ConfirmModal {
|
|
|
176
179
|
this.renderAgain = renderAgain;
|
|
177
180
|
this.close = close;
|
|
178
181
|
this.confirm = confirm;
|
|
182
|
+
this.selected = confirmByDefault;
|
|
179
183
|
}
|
|
180
184
|
invalidate() { }
|
|
181
185
|
render(width) {
|
|
@@ -207,6 +211,36 @@ export class ConfirmModal {
|
|
|
207
211
|
this.renderAgain();
|
|
208
212
|
}
|
|
209
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Why a request asks, in one plain line (trial polish #2): the danger check's verdict when it has
|
|
216
|
+
* one, otherwise what the session mode leaves to a person.
|
|
217
|
+
*/
|
|
218
|
+
export function approvalReason(value, mode) {
|
|
219
|
+
const danger = /^Dangerous \(([^)]+)\): (.*)$/m.exec(value);
|
|
220
|
+
if (danger)
|
|
221
|
+
return `Asks because this command is dangerous (${danger[1]}): ${danger[2]}`;
|
|
222
|
+
const outside = /^Outside the sandbox: (.*?)\. This one command/m.exec(value);
|
|
223
|
+
if (outside)
|
|
224
|
+
return `Asks because it wants to run outside the sandbox: ${outside[1]}`;
|
|
225
|
+
const edit = /^(---|\+\+\+) /m.test(value) && !/\nCommand:\n/.test(value);
|
|
226
|
+
if (mode === 'review')
|
|
227
|
+
return 'Asks because review mode asks before every edit and command.';
|
|
228
|
+
if (mode === 'accept-edits' && !edit)
|
|
229
|
+
return 'Asks because accept edits still asks before commands.';
|
|
230
|
+
if (mode === 'auto')
|
|
231
|
+
return edit
|
|
232
|
+
? 'Asks because this edit is outside what auto mode approves on its own.'
|
|
233
|
+
: 'Asks because auto mode approves only sandboxed commands it can read.';
|
|
234
|
+
return 'Asks because nothing approves this request on its own.';
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The approval prompt (phase 2a): what will run, in plain words, with the exact binding (approval
|
|
238
|
+
* id, digest, revision, expiry) one key away rather than in the way. Desk sessions can also approve
|
|
239
|
+
* for the rest of the session or always for this repo; strict work only ever approves once. A
|
|
240
|
+
* request longer than the screen must be read to the end before anything but Deny is possible, and
|
|
241
|
+
* a request that changed or expired cannot be approved at all.
|
|
242
|
+
*/
|
|
243
|
+
export { onceOnlyReason } from './approval-rules.js';
|
|
210
244
|
export class ApprovalModal {
|
|
211
245
|
approval;
|
|
212
246
|
operation;
|
|
@@ -216,14 +250,25 @@ export class ApprovalModal {
|
|
|
216
250
|
renderAgain;
|
|
217
251
|
close;
|
|
218
252
|
decide;
|
|
253
|
+
offerRules;
|
|
254
|
+
onceOnly;
|
|
255
|
+
options;
|
|
219
256
|
focused = false;
|
|
220
257
|
offset = 0;
|
|
221
258
|
reviewed = false;
|
|
222
|
-
|
|
259
|
+
showDetails = false;
|
|
260
|
+
choice = 0;
|
|
223
261
|
total = 0;
|
|
224
262
|
pageSize = 1;
|
|
225
263
|
stale = false;
|
|
226
|
-
constructor(approval, operation, theme, rows, current, renderAgain, close, decide
|
|
264
|
+
constructor(approval, operation, theme, rows, current, renderAgain, close, decide, offerRules = false,
|
|
265
|
+
/** Why only Approve once and Deny are offered, shown above the choices. */
|
|
266
|
+
onceOnly,
|
|
267
|
+
/**
|
|
268
|
+
* Desk sessions (trial polish #1) see the change and decide; there is no read-to-the-end gate
|
|
269
|
+
* there, since the person chose the session and its mode. Strict work keeps the gate.
|
|
270
|
+
*/
|
|
271
|
+
options = {}) {
|
|
227
272
|
this.approval = approval;
|
|
228
273
|
this.operation = operation;
|
|
229
274
|
this.theme = theme;
|
|
@@ -232,21 +277,70 @@ export class ApprovalModal {
|
|
|
232
277
|
this.renderAgain = renderAgain;
|
|
233
278
|
this.close = close;
|
|
234
279
|
this.decide = decide;
|
|
280
|
+
this.offerRules = offerRules;
|
|
281
|
+
this.onceOnly = onceOnly;
|
|
282
|
+
this.options = options;
|
|
283
|
+
this.choice = 1;
|
|
284
|
+
}
|
|
285
|
+
choices() {
|
|
286
|
+
return [
|
|
287
|
+
{ decision: 'deny', key: 'n', label: 'Deny', colour: 'amber' },
|
|
288
|
+
{ decision: 'approve', key: 'y', label: 'Approve once', colour: 'jade' },
|
|
289
|
+
...(this.options.desk
|
|
290
|
+
? [
|
|
291
|
+
{
|
|
292
|
+
decision: 'deny-feedback',
|
|
293
|
+
key: 'f',
|
|
294
|
+
label: 'Deny and tell it why',
|
|
295
|
+
colour: 'amber',
|
|
296
|
+
},
|
|
297
|
+
]
|
|
298
|
+
: []),
|
|
299
|
+
...(this.offerRules
|
|
300
|
+
? [
|
|
301
|
+
{
|
|
302
|
+
decision: 'approve-session',
|
|
303
|
+
key: 's',
|
|
304
|
+
label: 'Approve for this session',
|
|
305
|
+
colour: 'jade',
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
decision: 'always',
|
|
309
|
+
key: 'a',
|
|
310
|
+
label: 'Always allow in this repo',
|
|
311
|
+
colour: 'jade',
|
|
312
|
+
},
|
|
313
|
+
]
|
|
314
|
+
: []),
|
|
315
|
+
];
|
|
235
316
|
}
|
|
236
317
|
invalidate() { }
|
|
237
318
|
render(width) {
|
|
238
319
|
const a = this.approval, op = this.operation, t = this.theme, inner = Math.max(1, width - 4);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
320
|
+
// A diff reads as a diff: added lines jade, removed lines red, hunk headers muted.
|
|
321
|
+
const diffInk = (line) => /^(\+\+\+|---) /.test(line) || line.startsWith('@@')
|
|
322
|
+
? t.ink(line, 'muted')
|
|
323
|
+
: line.startsWith('+')
|
|
324
|
+
? t.ink(line, 'jade')
|
|
325
|
+
: line.startsWith('-')
|
|
326
|
+
? t.ink(line, 'red')
|
|
327
|
+
: t.ink(line);
|
|
328
|
+
const body = [
|
|
329
|
+
...[safeText(op.target), ''].flatMap((line) => wrapTextWithAnsi(t.ink(line), inner)),
|
|
330
|
+
...safeText(op.value)
|
|
331
|
+
.split('\n')
|
|
332
|
+
.flatMap((line) => wrapTextWithAnsi(diffInk(line), inner)),
|
|
333
|
+
...[
|
|
334
|
+
...(this.showDetails
|
|
335
|
+
? [
|
|
336
|
+
'',
|
|
337
|
+
`Approval ${a.id} · revision ${a.revision} · worker epoch ${a.worker_epoch}`,
|
|
338
|
+
`Digest ${a.operation_digest}`,
|
|
339
|
+
`Target version ${safeText(op.target_version)} · expires ${new Date(a.expires_at).toISOString()}`,
|
|
340
|
+
]
|
|
341
|
+
: []),
|
|
342
|
+
].flatMap((line) => wrapTextWithAnsi(t.ink(line), inner)),
|
|
343
|
+
];
|
|
250
344
|
const current = this.current();
|
|
251
345
|
this.stale =
|
|
252
346
|
!current ||
|
|
@@ -254,33 +348,54 @@ export class ApprovalModal {
|
|
|
254
348
|
current.operation_digest !== a.operation_digest ||
|
|
255
349
|
current.revision !== a.revision ||
|
|
256
350
|
Date.now() >= a.expires_at;
|
|
257
|
-
this.total =
|
|
351
|
+
this.total = body.length;
|
|
258
352
|
this.pageSize = Math.max(1, Math.min(18, this.rows() - 11));
|
|
259
353
|
this.offset = Math.max(0, Math.min(this.offset, Math.max(0, this.total - this.pageSize)));
|
|
260
|
-
if (this.rows() >= 12 && this.offset + this.pageSize >= this.total)
|
|
354
|
+
if (this.options.desk || (this.rows() >= 12 && this.offset + this.pageSize >= this.total))
|
|
261
355
|
this.reviewed = true;
|
|
356
|
+
const below = this.total - (this.offset + this.pageSize);
|
|
262
357
|
const disabled = !this.reviewed || this.stale;
|
|
263
|
-
|
|
264
|
-
|
|
358
|
+
const choices = this.choices();
|
|
359
|
+
this.choice = Math.max(0, Math.min(this.choice, choices.length - 1));
|
|
360
|
+
return framed(t, 'Approve?', [
|
|
361
|
+
...body.slice(this.offset, this.offset + this.pageSize),
|
|
265
362
|
'',
|
|
266
|
-
|
|
267
|
-
? 'This request changed or expired. Close and refresh.'
|
|
363
|
+
...(this.stale
|
|
364
|
+
? [t.ink('This request changed or expired. Close and refresh.', 'red')]
|
|
268
365
|
: this.reviewed
|
|
269
|
-
?
|
|
270
|
-
:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
366
|
+
? []
|
|
367
|
+
: [
|
|
368
|
+
t.ink(`Read to the end to approve · ${this.offset + 1}–${Math.min(this.total, this.offset + this.pageSize)} of ${this.total}`, 'muted'),
|
|
369
|
+
]),
|
|
370
|
+
...(this.options.desk && below > 0
|
|
371
|
+
? [t.ink(`${below} more lines · ↓ to scroll`, 'muted')]
|
|
372
|
+
: []),
|
|
373
|
+
...(this.options.reason
|
|
374
|
+
? wrapTextWithAnsi(t.ink(this.options.reason, 'amber'), inner)
|
|
375
|
+
: []),
|
|
376
|
+
...(this.onceOnly ? wrapTextWithAnsi(t.ink(this.onceOnly, 'muted'), inner) : []),
|
|
377
|
+
...choices.map((c, i) => t.ink((i === this.choice ? '› ' : ' ') + `${c.key} ${c.label}`, !c.decision.startsWith('deny') && disabled
|
|
378
|
+
? 'muted'
|
|
379
|
+
: i === this.choice
|
|
380
|
+
? c.colour
|
|
381
|
+
: 'muted')),
|
|
274
382
|
'',
|
|
275
|
-
t.key('
|
|
276
|
-
' ' +
|
|
277
|
-
t.key('←→', 'choose') +
|
|
383
|
+
t.key('←→', 'choose') +
|
|
278
384
|
' ' +
|
|
279
385
|
t.key('enter', 'decide') +
|
|
280
386
|
' ' +
|
|
387
|
+
t.key('d', this.showDetails ? 'hide details' : 'details') +
|
|
388
|
+
' ' +
|
|
389
|
+
t.key('↑↓', 'scroll') +
|
|
390
|
+
' ' +
|
|
281
391
|
t.key('esc', 'back'),
|
|
282
392
|
], width);
|
|
283
393
|
}
|
|
394
|
+
pick(decision) {
|
|
395
|
+
if (decision !== 'deny' && decision !== 'deny-feedback' && (this.stale || !this.reviewed))
|
|
396
|
+
return;
|
|
397
|
+
this.decide(decision);
|
|
398
|
+
}
|
|
284
399
|
handleInput(data) {
|
|
285
400
|
const current = this.current(), a = this.approval;
|
|
286
401
|
this.stale =
|
|
@@ -290,8 +405,14 @@ export class ApprovalModal {
|
|
|
290
405
|
current.revision !== a.revision ||
|
|
291
406
|
current.worker_epoch !== a.worker_epoch ||
|
|
292
407
|
Date.now() >= a.expires_at;
|
|
408
|
+
const choices = this.choices();
|
|
409
|
+
const byKey = choices.find((c) => c.key === data);
|
|
293
410
|
if (matchesKey(data, 'escape'))
|
|
294
411
|
this.close();
|
|
412
|
+
else if (byKey)
|
|
413
|
+
this.pick(byKey.decision);
|
|
414
|
+
else if (data === 'd')
|
|
415
|
+
this.showDetails = !this.showDetails;
|
|
295
416
|
else if (matchesKey(data, 'down'))
|
|
296
417
|
this.offset++;
|
|
297
418
|
else if (matchesKey(data, 'up'))
|
|
@@ -304,10 +425,12 @@ export class ApprovalModal {
|
|
|
304
425
|
this.offset = this.total;
|
|
305
426
|
else if (matchesKey(data, 'home'))
|
|
306
427
|
this.offset = 0;
|
|
307
|
-
else if (matchesKey(data, '
|
|
308
|
-
this.
|
|
309
|
-
else if (matchesKey(data, '
|
|
310
|
-
this.
|
|
428
|
+
else if (matchesKey(data, 'right') || matchesKey(data, 'tab'))
|
|
429
|
+
this.choice = (this.choice + 1) % choices.length;
|
|
430
|
+
else if (matchesKey(data, 'left'))
|
|
431
|
+
this.choice = (this.choice + choices.length - 1) % choices.length;
|
|
432
|
+
else if (matchesKey(data, 'enter'))
|
|
433
|
+
this.pick(choices[this.choice].decision);
|
|
311
434
|
this.renderAgain();
|
|
312
435
|
}
|
|
313
436
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function notifyMode(env = process.env) {
|
|
3
|
+
const v = env.SALAAM_NOTIFY;
|
|
4
|
+
return v === 'off' || v === 'desktop' ? v : 'bell';
|
|
5
|
+
}
|
|
6
|
+
/** The events worth interrupting someone for, in plain words; everything else stays quiet. */
|
|
7
|
+
export function notificationFor(event) {
|
|
8
|
+
const p = (event.payload ?? {});
|
|
9
|
+
if (event.kind === 'approval.requested')
|
|
10
|
+
return 'Salaam is waiting for your approval';
|
|
11
|
+
if (event.kind === 'attempt.completed')
|
|
12
|
+
return 'Salaam finished the turn';
|
|
13
|
+
if (event.kind === 'attempt.failed')
|
|
14
|
+
return 'Salaam’s turn failed';
|
|
15
|
+
if (event.kind === 'local.task_finished') {
|
|
16
|
+
const how = p.state === 'exited'
|
|
17
|
+
? p.exit_code === 0
|
|
18
|
+
? 'finished'
|
|
19
|
+
: `exited with ${String(p.exit_code)}`
|
|
20
|
+
: String(p.state).replace('_', ' ');
|
|
21
|
+
return `Background command ${how}: ${String(p.command ?? '').slice(0, 80)}`;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function notify(message, mode, platform = process.platform, run = detached, bell = (s) => void process.stdout.write(s)) {
|
|
26
|
+
if (mode === 'off')
|
|
27
|
+
return;
|
|
28
|
+
bell('\x07');
|
|
29
|
+
if (mode !== 'desktop')
|
|
30
|
+
return;
|
|
31
|
+
// Passed as an argument, never interpolated into the script: a command name cannot inject.
|
|
32
|
+
if (platform === 'darwin')
|
|
33
|
+
run('osascript', [
|
|
34
|
+
'-e',
|
|
35
|
+
'on run argv\ndisplay notification (item 1 of argv) with title "Salaam"\nend run',
|
|
36
|
+
message,
|
|
37
|
+
]);
|
|
38
|
+
else if (platform === 'linux')
|
|
39
|
+
run('notify-send', ['Salaam', message]);
|
|
40
|
+
}
|
|
41
|
+
function detached(cmd, args) {
|
|
42
|
+
try {
|
|
43
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
44
|
+
child.on('error', () => { });
|
|
45
|
+
child.unref();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* the bell already rang */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function jobsText(tasks, outputs) {
|
|
52
|
+
if (!tasks.length)
|
|
53
|
+
return 'No background commands in this session.\nThe model starts one with salaam_local background: true.';
|
|
54
|
+
return tasks
|
|
55
|
+
.map((t, i) => {
|
|
56
|
+
const state = t.state === 'exited' ? `exited ${String(t.exit_code)}` : t.state;
|
|
57
|
+
const tail = outputs[i]?.output.split('\n').slice(-12).join('\n').trimEnd();
|
|
58
|
+
return `${t.id} ${state} ${t.command}${tail ? '\n' + tail.replace(/^/gm, ' │ ') : ''}`;
|
|
59
|
+
})
|
|
60
|
+
.join('\n\n')
|
|
61
|
+
.concat('\n\n/jobs stop <id> stops one.');
|
|
62
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { relativeTime } from './session-pick.js';
|
|
2
|
+
const SHOWN = 12;
|
|
3
|
+
/** Background commands may keep writing while a review or rewind is open (operator ruling). */
|
|
4
|
+
export function backgroundNote(running) {
|
|
5
|
+
if (!running)
|
|
6
|
+
return null;
|
|
7
|
+
return running === 1
|
|
8
|
+
? '1 background task running; its writes may change files. /jobs shows it.'
|
|
9
|
+
: `${running} background tasks running; their writes may change files. /jobs shows them.`;
|
|
10
|
+
}
|
|
11
|
+
export function changeCounts(change) {
|
|
12
|
+
return change.added === null ? 'binary' : `+${change.added} −${change.removed}`;
|
|
13
|
+
}
|
|
14
|
+
/** What one confirm of a rewind does, in words. `changes` are from the folder now to the
|
|
15
|
+
* checkpoint, so a file the checkpoint has and the folder lacks is brought back. */
|
|
16
|
+
export function rewindSummary(review) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
if (review.mode === 'conversation')
|
|
19
|
+
lines.push('Files are not changed.');
|
|
20
|
+
else if (!review.changes.length)
|
|
21
|
+
lines.push('Files already match the checkpoint.');
|
|
22
|
+
else {
|
|
23
|
+
lines.push(`Files: ${review.changes.length} to put back as they were before turn ${review.turn}.`);
|
|
24
|
+
for (const change of review.changes.slice(0, SHOWN))
|
|
25
|
+
lines.push(' ' +
|
|
26
|
+
(change.status === 'modified'
|
|
27
|
+
? 'restore '
|
|
28
|
+
: change.status === 'added'
|
|
29
|
+
? 'bring back '
|
|
30
|
+
: 'remove ') +
|
|
31
|
+
change.path +
|
|
32
|
+
` (${changeCounts(change)})`);
|
|
33
|
+
if (review.changes.length > SHOWN)
|
|
34
|
+
lines.push(` … and ${review.changes.length - SHOWN} more files`);
|
|
35
|
+
}
|
|
36
|
+
lines.push(review.mode === 'files'
|
|
37
|
+
? 'The conversation is kept as it is.'
|
|
38
|
+
: `The conversation continues in a new session from before turn ${review.turn}; this session stays as it is, and its instruction comes back to edit.`);
|
|
39
|
+
lines.push('Commands and anything outside this folder are not undone.');
|
|
40
|
+
const note = backgroundNote(review.background_running);
|
|
41
|
+
if (note)
|
|
42
|
+
lines.push(note);
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
}
|
|
45
|
+
/** /rewind's turn list: newest first, with how long ago each turn started. */
|
|
46
|
+
export function rewindItems(points, now = Date.now()) {
|
|
47
|
+
return points
|
|
48
|
+
.slice()
|
|
49
|
+
.reverse()
|
|
50
|
+
.map((p) => ({
|
|
51
|
+
value: p.attempt_id,
|
|
52
|
+
label: `Turn ${p.turn} · ${p.text.split('\n')[0].slice(0, 80)}`,
|
|
53
|
+
description: relativeTime(p.at, now),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { matchesKey } from '@earendil-works/pi-tui';
|
|
2
|
+
/** Interactive local coding: the desk-side mode, not admitted or strict work. */
|
|
3
|
+
export function interactiveCoding(snapshot) {
|
|
4
|
+
return !!snapshot?.capabilities.includes('local-coding-reviewed');
|
|
5
|
+
}
|
|
6
|
+
/** The turn the model is working on now, if any. */
|
|
7
|
+
export function runningAttempt(snapshot) {
|
|
8
|
+
return snapshot?.attempts.find((a) => a.state === 'running')?.id;
|
|
9
|
+
}
|
|
10
|
+
export function turnKeyAction(input) {
|
|
11
|
+
const { key, text, snapshot } = input;
|
|
12
|
+
if (input.modal || input.autocomplete)
|
|
13
|
+
return key === 'enter' ? 'submit' : null;
|
|
14
|
+
const live = interactiveCoding(snapshot) && runningAttempt(snapshot) !== undefined;
|
|
15
|
+
const words = text.trim() !== '' && !text.trimStart().startsWith('/');
|
|
16
|
+
if (key === 'escape')
|
|
17
|
+
return live ? 'interrupt' : null;
|
|
18
|
+
if (key === 'tab')
|
|
19
|
+
return live && words ? 'queue' : null;
|
|
20
|
+
return live && words ? 'steer' : 'submit';
|
|
21
|
+
}
|
|
22
|
+
/** The key the TUI saw, as one of the three this module decides, or undefined for any other. */
|
|
23
|
+
export function turnKey(data) {
|
|
24
|
+
return matchesKey(data, 'escape')
|
|
25
|
+
? 'escape'
|
|
26
|
+
: matchesKey(data, 'enter')
|
|
27
|
+
? 'enter'
|
|
28
|
+
: matchesKey(data, 'tab')
|
|
29
|
+
? 'tab'
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
32
|
+
/** What /cancel offers, the running turn first, then work queued behind it. */
|
|
33
|
+
export function cancelChoices(snapshot) {
|
|
34
|
+
const order = ['running', 'dispatching', 'accepted'];
|
|
35
|
+
return snapshot.attempts
|
|
36
|
+
.filter((a) => order.includes(a.state))
|
|
37
|
+
.sort((a, b) => order.indexOf(a.state) - order.indexOf(b.state))
|
|
38
|
+
.map((a) => ({
|
|
39
|
+
id: a.id,
|
|
40
|
+
label: (a.state === 'accepted' ? 'queued' : a.state) + ' · ' + a.id.slice(0, 8),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
/** Instructions waiting behind the running turn, with their text, so they can be edited or removed. */
|
|
44
|
+
export function queuedItems(snapshot) {
|
|
45
|
+
return snapshot.attempts
|
|
46
|
+
.filter((a) => a.state === 'accepted')
|
|
47
|
+
.map((a) => ({
|
|
48
|
+
attempt: a.id,
|
|
49
|
+
submission: a.submission_id,
|
|
50
|
+
text: snapshot.submissions.find((s) => s.id === a.submission_id)?.text ?? '',
|
|
51
|
+
}));
|
|
52
|
+
}
|