@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. 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
+ }
@@ -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
- this.theme.ink(this.allowCoding
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
- approve = false;
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
- const details = [
240
- `Operation: ${op.kind}`,
241
- `Target: ${safeText(op.target)}`,
242
- 'Value:',
243
- safeText(op.value),
244
- `Expected target version: ${safeText(op.target_version)}`,
245
- `Approval: ${a.id}`,
246
- `Digest: ${a.operation_digest}`,
247
- `Revision: ${a.revision} · worker epoch: ${a.worker_epoch}`,
248
- `Expires: ${new Date(a.expires_at).toISOString()}`,
249
- ].flatMap((line) => wrapTextWithAnsi(t.ink(line), inner));
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 = details.length;
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
- return framed(t, 'Review approval', [
264
- ...details.slice(this.offset, this.offset + this.pageSize),
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
- t.ink(this.stale
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
- ? 'Exact request binding reviewed.'
270
- : `Scroll to the end to enable approval · ${this.offset + 1}–${Math.min(this.total, this.offset + this.pageSize)} of ${this.total}`, this.stale ? 'red' : 'muted'),
271
- t.ink((this.approve ? ' ' : '› ') + 'Deny', this.approve ? 'muted' : 'amber') +
272
- ' ' +
273
- t.ink((this.approve ? '› ' : ' ') + 'Approve once', disabled ? 'muted' : 'jade'),
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('↑↓', 'scroll') +
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, 'left') || matchesKey(data, 'right') || matchesKey(data, 'tab'))
308
- this.approve = !this.approve;
309
- else if (matchesKey(data, 'enter') && !this.stale && (!this.approve || this.reviewed))
310
- this.decide(this.approve ? 'approve' : 'deny');
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
+ }