@superjackfruitlabs/salaam 0.0.3 → 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 (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -0,0 +1,152 @@
1
+ import type { KeyId } from '@earendil-works/pi-tui';
2
+
3
+ /**
4
+ * The TUI's keys in one table (trial polish C, 2026-10-31). The global bindings in tui.ts and the
5
+ * `?` key sheet both read this, so the sheet cannot drift from what the keys do.
6
+ */
7
+ export type GlobalAction =
8
+ | 'palette'
9
+ | 'sessions'
10
+ | 'new'
11
+ | 'approvals'
12
+ | 'changes'
13
+ | 'commands'
14
+ | 'rail'
15
+ | 'transcript'
16
+ | 'refresh'
17
+ | 'editor'
18
+ | 'mode'
19
+ | 'help';
20
+
21
+ export const GLOBAL_KEYS: readonly { key: KeyId; action: GlobalAction; label: string }[] = [
22
+ { key: 'ctrl+k', action: 'palette', label: 'all actions' },
23
+ { key: 'f2', action: 'palette', label: 'all actions' },
24
+ { key: 'ctrl+p', action: 'sessions', label: 'sessions' },
25
+ { key: 'ctrl+n', action: 'new', label: 'new session' },
26
+ { key: 'ctrl+o', action: 'approvals', label: 'approvals' },
27
+ { key: 'alt+g', action: 'changes', label: 'changes' },
28
+ { key: 'ctrl+t', action: 'commands', label: 'commands' },
29
+ { key: 'ctrl+b', action: 'rail', label: 'show or hide the side panel' },
30
+ { key: 'ctrl+x', action: 'transcript', label: 'full transcript on or off' },
31
+ { key: 'f5', action: 'refresh', label: 'refresh' },
32
+ { key: 'ctrl+g', action: 'editor', label: 'external prompt editor' },
33
+ { key: 'shift+tab', action: 'mode', label: 'next mode' },
34
+ { key: 'f1', action: 'help', label: 'these keys' },
35
+ ];
36
+
37
+ /** The per-file review on the Changes tab of a desk session. */
38
+ export const REVIEW_KEYS = {
39
+ nextFile: { key: 'alt+right', label: 'next file' },
40
+ previousFile: { key: 'alt+left', label: 'previous file' },
41
+ nextHunk: { key: 'alt+down', label: 'next hunk' },
42
+ previousHunk: { key: 'alt+up', label: 'previous hunk' },
43
+ accept: { key: 'a', label: 'accept the file or hunk' },
44
+ revert: { key: 'r', label: 'revert it (asks first)' },
45
+ instruct: { key: 'i', label: 'tell the model about it' },
46
+ } as const;
47
+
48
+ /** Keys while a desk turn runs (tui-turn-keys.ts decides them). */
49
+ export const TURN_KEYS = [
50
+ { key: 'escape', label: 'stop the turn' },
51
+ { key: 'enter', label: 'steer it' },
52
+ { key: 'tab', label: 'queue for after' },
53
+ ] as const;
54
+
55
+ const COMPOSER: { label: string; description?: string }[] = [
56
+ { label: 'Enter · send', description: 'The durable outbox saves it before delivery' },
57
+ { label: 'Alt+Enter / Ctrl+J · new line' },
58
+ { label: 'Tab · switch view', description: 'Conversation · Changes · Commands · Context' },
59
+ { label: 'PageUp / PageDown · scroll', description: 'Ctrl+Home/End jump to the top/bottom' },
60
+ { label: '@ · attach a file or image' },
61
+ { label: '/ · slash commands' },
62
+ {
63
+ label: 'Ctrl+R · search sent messages',
64
+ description: 'Ctrl+R for older, Enter keeps, Esc restores',
65
+ },
66
+ {
67
+ label: 'Ctrl+A/E · Ctrl+U/K/W · Ctrl+Y · Ctrl+-',
68
+ description: 'Line start/end, kill to start/end/word, yank, undo; Alt+B/F words',
69
+ },
70
+ {
71
+ label: '!command · run a shell command',
72
+ description: 'Same approval, sandbox and danger check',
73
+ },
74
+ {
75
+ label: 'Ctrl+C · stop, clear, then leave',
76
+ description: 'Stops a running turn, else clears text; twice on empty leaves. Work continues.',
77
+ },
78
+ ];
79
+ const CODING_COMMANDS = [
80
+ { label: '/rewind · go back to an earlier turn' },
81
+ { label: '/jobs · background commands' },
82
+ { label: '/undo · revert the last edit' },
83
+ ];
84
+
85
+ export function keyLabel(key: string): string {
86
+ const names: Record<string, string> = { escape: 'Esc', enter: 'Enter', tab: 'Tab' };
87
+ if (names[key]) return names[key];
88
+ if (key.length === 1) return key;
89
+ return key
90
+ .split('+')
91
+ .map((part) =>
92
+ part === 'right'
93
+ ? '→'
94
+ : part === 'left'
95
+ ? '←'
96
+ : part === 'up'
97
+ ? '↑'
98
+ : part === 'down'
99
+ ? '↓'
100
+ : part.length <= 2
101
+ ? part.toUpperCase()
102
+ : part[0]!.toUpperCase() + part.slice(1),
103
+ )
104
+ .join('+');
105
+ }
106
+
107
+ export interface KeyContext {
108
+ tab: string;
109
+ coding: boolean;
110
+ running: boolean;
111
+ /** Your own slash commands (Phase 4b), listed in the conversation sheet. */
112
+ custom?: readonly { name: string; description: string; argumentHint?: string }[];
113
+ }
114
+
115
+ export function keySheet(c: KeyContext): {
116
+ title: string;
117
+ rows: { label: string; description?: string }[];
118
+ } {
119
+ const global = GLOBAL_KEYS.map((k) => ({ label: keyLabel(k.key) + ' · ' + k.label }));
120
+ const approvals = { label: 'Approvals show their own keys along the bottom' };
121
+ if (c.tab === 'changes' && c.coding)
122
+ return {
123
+ title: 'Keys · Review changes',
124
+ rows: [
125
+ ...Object.values(REVIEW_KEYS).map((k) => ({ label: keyLabel(k.key) + ' · ' + k.label })),
126
+ { label: 'a, r and i work while the composer is empty' },
127
+ ...global,
128
+ ],
129
+ };
130
+ if (c.coding && c.running)
131
+ return {
132
+ title: 'Keys · Turn running',
133
+ rows: [
134
+ ...TURN_KEYS.map((k) => ({ label: keyLabel(k.key) + ' · ' + k.label })),
135
+ ...global,
136
+ approvals,
137
+ ],
138
+ };
139
+ return {
140
+ title: 'Keys · Conversation',
141
+ rows: [
142
+ ...COMPOSER,
143
+ ...(c.coding ? CODING_COMMANDS : []),
144
+ ...(c.custom ?? []).map((k) => ({
145
+ label: `/${k.name}${k.argumentHint ? ' ' + k.argumentHint : ''} · your command`,
146
+ description: k.description,
147
+ })),
148
+ ...global,
149
+ approvals,
150
+ ],
151
+ };
152
+ }
@@ -0,0 +1,43 @@
1
+ import { CombinedAutocompleteProvider } from '@earendil-works/pi-tui';
2
+ import { basename } from 'node:path';
3
+ import { listWorkspaceFiles, rankFiles } from './mentions.js';
4
+
5
+ /**
6
+ * `@` completes workspace files from git's own listing (ignored files stay out) instead of
7
+ * pi-tui's `fd` walk, which is absent on most desks. The listing is cached for 5 s.
8
+ */
9
+ export class MentionAutocompleteProvider extends CombinedAutocompleteProvider {
10
+ private cache?: { at: number; files: string[] };
11
+ constructor(
12
+ commands: ConstructorParameters<typeof CombinedAutocompleteProvider>[0],
13
+ private readonly root: string,
14
+ ) {
15
+ super(commands, root, null);
16
+ }
17
+ files(): string[] {
18
+ if (!this.cache || Date.now() - this.cache.at > 5000)
19
+ this.cache = { at: Date.now(), files: listWorkspaceFiles(this.root) };
20
+ return this.cache.files;
21
+ }
22
+ override async getSuggestions(
23
+ lines: string[],
24
+ cursorLine: number,
25
+ cursorCol: number,
26
+ options: { signal: AbortSignal; force?: boolean },
27
+ ): ReturnType<CombinedAutocompleteProvider['getSuggestions']> {
28
+ const before = (lines[cursorLine] ?? '').slice(0, cursorCol);
29
+ const match = /(?:^|[\s([{])(@"[^"]*|@[^\s"]*)$/.exec(before);
30
+ if (!match) return super.getSuggestions(lines, cursorLine, cursorCol, options);
31
+ if (options.signal.aborted) return null;
32
+ const prefix = match[1]!;
33
+ const quoted = prefix.startsWith('@"');
34
+ const items = rankFiles(this.files(), prefix.slice(quoted ? 2 : 1))
35
+ .slice(0, 20)
36
+ .map((file) => ({
37
+ value: quoted || /\s/.test(file) ? `@"${file}"` : `@${file}`,
38
+ label: basename(file),
39
+ description: file,
40
+ }));
41
+ return items.length ? { items, prefix } : null;
42
+ }
43
+ }
package/src/tui-modals.ts CHANGED
@@ -152,13 +152,17 @@ export class FormModal implements Component, Focusable {
152
152
  this.theme.ink(this.labels?.field ?? 'Project directory', 'muted'),
153
153
  ...this.input.render(Math.max(1, width - 4)).map((line) => this.theme.ink(line)),
154
154
  '',
155
- this.theme.ink(
156
- this.allowCoding
157
- ? this.coding
158
- ? '[✓] Coding workspace · guarded tools'
159
- : '[ ] Conversation session'
160
- : (this.labels?.hint ?? 'Conversation session · coding service is off'),
161
- this.coding ? 'jade' : 'muted',
155
+ // A long hint (the YOLO warning) wraps instead of running past the frame.
156
+ ...wrapTextWithAnsi(
157
+ this.theme.ink(
158
+ this.allowCoding
159
+ ? this.coding
160
+ ? '[✓] Coding workspace · guarded tools'
161
+ : '[ ] Conversation session'
162
+ : (this.labels?.hint ?? 'Conversation session · coding service is off'),
163
+ this.coding ? 'jade' : 'muted',
164
+ ),
165
+ Math.max(1, width - 4),
162
166
  ),
163
167
  '',
164
168
  this.theme.key('enter', this.labels?.submit ?? 'create') +
@@ -191,7 +195,11 @@ export class ConfirmModal implements Component, Focusable {
191
195
  readonly renderAgain: () => void,
192
196
  readonly close: () => void,
193
197
  readonly confirm: () => void,
194
- ) {}
198
+ /** Start on the action rather than on "Keep current state" (cancel a running turn). */
199
+ confirmByDefault = false,
200
+ ) {
201
+ this.selected = confirmByDefault;
202
+ }
195
203
  invalidate(): void {}
196
204
  render(width: number): string[] {
197
205
  return framed(
@@ -230,11 +238,40 @@ export class ConfirmModal implements Component, Focusable {
230
238
  this.renderAgain();
231
239
  }
232
240
  }
241
+ export type ApprovalDecision = 'approve' | 'approve-session' | 'always' | 'deny' | 'deny-feedback';
242
+ /**
243
+ * Why a request asks, in one plain line (trial polish #2): the danger check's verdict when it has
244
+ * one, otherwise what the session mode leaves to a person.
245
+ */
246
+ export function approvalReason(value: string, mode: string | undefined): string {
247
+ const danger = /^Dangerous \(([^)]+)\): (.*)$/m.exec(value);
248
+ if (danger) return `Asks because this command is dangerous (${danger[1]}): ${danger[2]}`;
249
+ const outside = /^Outside the sandbox: (.*?)\. This one command/m.exec(value);
250
+ if (outside) return `Asks because it wants to run outside the sandbox: ${outside[1]}`;
251
+ const edit = /^(---|\+\+\+) /m.test(value) && !/\nCommand:\n/.test(value);
252
+ if (mode === 'review') return 'Asks because review mode asks before every edit and command.';
253
+ if (mode === 'accept-edits' && !edit)
254
+ return 'Asks because accept edits still asks before commands.';
255
+ if (mode === 'auto')
256
+ return edit
257
+ ? 'Asks because this edit is outside what auto mode approves on its own.'
258
+ : 'Asks because auto mode approves only sandboxed commands it can read.';
259
+ return 'Asks because nothing approves this request on its own.';
260
+ }
261
+ /**
262
+ * The approval prompt (phase 2a): what will run, in plain words, with the exact binding (approval
263
+ * id, digest, revision, expiry) one key away rather than in the way. Desk sessions can also approve
264
+ * for the rest of the session or always for this repo; strict work only ever approves once. A
265
+ * request longer than the screen must be read to the end before anything but Deny is possible, and
266
+ * a request that changed or expired cannot be approved at all.
267
+ */
268
+ export { onceOnlyReason } from './approval-rules.js';
233
269
  export class ApprovalModal implements Component, Focusable {
234
270
  focused = false;
235
271
  offset = 0;
236
272
  reviewed = false;
237
- approve = false;
273
+ showDetails = false;
274
+ choice = 0;
238
275
  private total = 0;
239
276
  private pageSize = 1;
240
277
  private stale = false;
@@ -246,25 +283,86 @@ export class ApprovalModal implements Component, Focusable {
246
283
  readonly current: () => Approval | undefined,
247
284
  readonly renderAgain: () => void,
248
285
  readonly close: () => void,
249
- readonly decide: (decision: 'approve' | 'deny') => void,
250
- ) {}
286
+ readonly decide: (decision: ApprovalDecision) => void,
287
+ readonly offerRules = false,
288
+ /** Why only Approve once and Deny are offered, shown above the choices. */
289
+ readonly onceOnly?: string,
290
+ /**
291
+ * Desk sessions (trial polish #1) see the change and decide; there is no read-to-the-end gate
292
+ * there, since the person chose the session and its mode. Strict work keeps the gate.
293
+ */
294
+ readonly options: { desk?: boolean; reason?: string } = {},
295
+ ) {
296
+ this.choice = 1;
297
+ }
298
+ private choices(): {
299
+ decision: ApprovalDecision;
300
+ key: string;
301
+ label: string;
302
+ colour: 'amber' | 'jade';
303
+ }[] {
304
+ return [
305
+ { decision: 'deny', key: 'n', label: 'Deny', colour: 'amber' },
306
+ { decision: 'approve', key: 'y', label: 'Approve once', colour: 'jade' },
307
+ ...(this.options.desk
308
+ ? [
309
+ {
310
+ decision: 'deny-feedback' as const,
311
+ key: 'f',
312
+ label: 'Deny and tell it why',
313
+ colour: 'amber' as const,
314
+ },
315
+ ]
316
+ : []),
317
+ ...(this.offerRules
318
+ ? [
319
+ {
320
+ decision: 'approve-session' as const,
321
+ key: 's',
322
+ label: 'Approve for this session',
323
+ colour: 'jade' as const,
324
+ },
325
+ {
326
+ decision: 'always' as const,
327
+ key: 'a',
328
+ label: 'Always allow in this repo',
329
+ colour: 'jade' as const,
330
+ },
331
+ ]
332
+ : []),
333
+ ];
334
+ }
251
335
  invalidate(): void {}
252
336
  render(width: number): string[] {
253
337
  const a = this.approval,
254
338
  op = this.operation,
255
339
  t = this.theme,
256
340
  inner = Math.max(1, width - 4);
257
- const details = [
258
- `Operation: ${op.kind}`,
259
- `Target: ${safeText(op.target)}`,
260
- 'Value:',
261
- safeText(op.value),
262
- `Expected target version: ${safeText(op.target_version)}`,
263
- `Approval: ${a.id}`,
264
- `Digest: ${a.operation_digest}`,
265
- `Revision: ${a.revision} · worker epoch: ${a.worker_epoch}`,
266
- `Expires: ${new Date(a.expires_at).toISOString()}`,
267
- ].flatMap((line) => wrapTextWithAnsi(t.ink(line), inner));
341
+ // A diff reads as a diff: added lines jade, removed lines red, hunk headers muted.
342
+ const diffInk = (line: string) =>
343
+ /^(\+\+\+|---) /.test(line) || line.startsWith('@@')
344
+ ? t.ink(line, 'muted')
345
+ : line.startsWith('+')
346
+ ? t.ink(line, 'jade')
347
+ : line.startsWith('-')
348
+ ? t.ink(line, 'red')
349
+ : t.ink(line);
350
+ const body = [
351
+ ...[safeText(op.target), ''].flatMap((line) => wrapTextWithAnsi(t.ink(line), inner)),
352
+ ...safeText(op.value)
353
+ .split('\n')
354
+ .flatMap((line) => wrapTextWithAnsi(diffInk(line), inner)),
355
+ ...[
356
+ ...(this.showDetails
357
+ ? [
358
+ '',
359
+ `Approval ${a.id} · revision ${a.revision} · worker epoch ${a.worker_epoch}`,
360
+ `Digest ${a.operation_digest}`,
361
+ `Target version ${safeText(op.target_version)} · expires ${new Date(a.expires_at).toISOString()}`,
362
+ ]
363
+ : []),
364
+ ].flatMap((line) => wrapTextWithAnsi(t.ink(line), inner)),
365
+ ];
268
366
  const current = this.current();
269
367
  this.stale =
270
368
  !current ||
@@ -272,40 +370,67 @@ export class ApprovalModal implements Component, Focusable {
272
370
  current.operation_digest !== a.operation_digest ||
273
371
  current.revision !== a.revision ||
274
372
  Date.now() >= a.expires_at;
275
- this.total = details.length;
373
+ this.total = body.length;
276
374
  this.pageSize = Math.max(1, Math.min(18, this.rows() - 11));
277
375
  this.offset = Math.max(0, Math.min(this.offset, Math.max(0, this.total - this.pageSize)));
278
- if (this.rows() >= 12 && this.offset + this.pageSize >= this.total) this.reviewed = true;
376
+ if (this.options.desk || (this.rows() >= 12 && this.offset + this.pageSize >= this.total))
377
+ this.reviewed = true;
378
+ const below = this.total - (this.offset + this.pageSize);
279
379
  const disabled = !this.reviewed || this.stale;
380
+ const choices = this.choices();
381
+ this.choice = Math.max(0, Math.min(this.choice, choices.length - 1));
280
382
  return framed(
281
383
  t,
282
- 'Review approval',
384
+ 'Approve?',
283
385
  [
284
- ...details.slice(this.offset, this.offset + this.pageSize),
386
+ ...body.slice(this.offset, this.offset + this.pageSize),
285
387
  '',
286
- t.ink(
287
- this.stale
288
- ? 'This request changed or expired. Close and refresh.'
289
- : this.reviewed
290
- ? 'Exact request binding reviewed.'
291
- : `Scroll to the end to enable approval · ${this.offset + 1}–${Math.min(this.total, this.offset + this.pageSize)} of ${this.total}`,
292
- this.stale ? 'red' : 'muted',
388
+ ...(this.stale
389
+ ? [t.ink('This request changed or expired. Close and refresh.', 'red')]
390
+ : this.reviewed
391
+ ? []
392
+ : [
393
+ t.ink(
394
+ `Read to the end to approve · ${this.offset + 1}–${Math.min(this.total, this.offset + this.pageSize)} of ${this.total}`,
395
+ 'muted',
396
+ ),
397
+ ]),
398
+ ...(this.options.desk && below > 0
399
+ ? [t.ink(`${below} more lines · ↓ to scroll`, 'muted')]
400
+ : []),
401
+ ...(this.options.reason
402
+ ? wrapTextWithAnsi(t.ink(this.options.reason, 'amber'), inner)
403
+ : []),
404
+ ...(this.onceOnly ? wrapTextWithAnsi(t.ink(this.onceOnly, 'muted'), inner) : []),
405
+ ...choices.map((c, i) =>
406
+ t.ink(
407
+ (i === this.choice ? '› ' : ' ') + `${c.key} ${c.label}`,
408
+ !c.decision.startsWith('deny') && disabled
409
+ ? 'muted'
410
+ : i === this.choice
411
+ ? c.colour
412
+ : 'muted',
413
+ ),
293
414
  ),
294
- t.ink((this.approve ? ' ' : '› ') + 'Deny', this.approve ? 'muted' : 'amber') +
295
- ' ' +
296
- t.ink((this.approve ? '› ' : ' ') + 'Approve once', disabled ? 'muted' : 'jade'),
297
415
  '',
298
- t.key('↑↓', 'scroll') +
299
- ' ' +
300
- t.key('←→', 'choose') +
416
+ t.key('←→', 'choose') +
301
417
  ' ' +
302
418
  t.key('enter', 'decide') +
303
419
  ' ' +
420
+ t.key('d', this.showDetails ? 'hide details' : 'details') +
421
+ ' ' +
422
+ t.key('↑↓', 'scroll') +
423
+ ' ' +
304
424
  t.key('esc', 'back'),
305
425
  ],
306
426
  width,
307
427
  );
308
428
  }
429
+ private pick(decision: ApprovalDecision): void {
430
+ if (decision !== 'deny' && decision !== 'deny-feedback' && (this.stale || !this.reviewed))
431
+ return;
432
+ this.decide(decision);
433
+ }
309
434
  handleInput(data: string): void {
310
435
  const current = this.current(),
311
436
  a = this.approval;
@@ -316,17 +441,22 @@ export class ApprovalModal implements Component, Focusable {
316
441
  current.revision !== a.revision ||
317
442
  current.worker_epoch !== a.worker_epoch ||
318
443
  Date.now() >= a.expires_at;
444
+ const choices = this.choices();
445
+ const byKey = choices.find((c) => c.key === data);
319
446
  if (matchesKey(data, 'escape')) this.close();
447
+ else if (byKey) this.pick(byKey.decision);
448
+ else if (data === 'd') this.showDetails = !this.showDetails;
320
449
  else if (matchesKey(data, 'down')) this.offset++;
321
450
  else if (matchesKey(data, 'up')) this.offset--;
322
451
  else if (matchesKey(data, 'pageDown')) this.offset += this.pageSize;
323
452
  else if (matchesKey(data, 'pageUp')) this.offset -= this.pageSize;
324
453
  else if (matchesKey(data, 'end')) this.offset = this.total;
325
454
  else if (matchesKey(data, 'home')) this.offset = 0;
326
- else if (matchesKey(data, 'left') || matchesKey(data, 'right') || matchesKey(data, 'tab'))
327
- this.approve = !this.approve;
328
- else if (matchesKey(data, 'enter') && !this.stale && (!this.approve || this.reviewed))
329
- this.decide(this.approve ? 'approve' : 'deny');
455
+ else if (matchesKey(data, 'right') || matchesKey(data, 'tab'))
456
+ this.choice = (this.choice + 1) % choices.length;
457
+ else if (matchesKey(data, 'left'))
458
+ this.choice = (this.choice + choices.length - 1) % choices.length;
459
+ else if (matchesKey(data, 'enter')) this.pick(choices[this.choice]!.decision);
330
460
  this.renderAgain();
331
461
  }
332
462
  }
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { DurableEvent } from './protocol.js';
3
+
4
+ /** How the desk TUI gets a person's attention: off, a terminal bell, or a desktop notification
5
+ * (which also rings the bell). Set with SALAAM_NOTIFY; the bell is the default. */
6
+ export type NotifyMode = 'off' | 'bell' | 'desktop';
7
+ export function notifyMode(env: NodeJS.ProcessEnv = process.env): NotifyMode {
8
+ const v = env.SALAAM_NOTIFY;
9
+ return v === 'off' || v === 'desktop' ? v : 'bell';
10
+ }
11
+
12
+ /** The events worth interrupting someone for, in plain words; everything else stays quiet. */
13
+ export function notificationFor(event: DurableEvent): string | null {
14
+ const p = (event.payload ?? {}) as Record<string, unknown>;
15
+ if (event.kind === 'approval.requested') return 'Salaam is waiting for your approval';
16
+ if (event.kind === 'attempt.completed') return 'Salaam finished the turn';
17
+ if (event.kind === 'attempt.failed') return 'Salaam’s turn failed';
18
+ if (event.kind === 'local.task_finished') {
19
+ const how =
20
+ p.state === 'exited'
21
+ ? p.exit_code === 0
22
+ ? 'finished'
23
+ : `exited with ${String(p.exit_code)}`
24
+ : String(p.state).replace('_', ' ');
25
+ return `Background command ${how}: ${String(p.command ?? '').slice(0, 80)}`;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function notify(
31
+ message: string,
32
+ mode: NotifyMode,
33
+ platform: NodeJS.Platform = process.platform,
34
+ run: (cmd: string, args: string[]) => void = detached,
35
+ bell: (s: string) => void = (s) => void process.stdout.write(s),
36
+ ): void {
37
+ if (mode === 'off') return;
38
+ bell('\x07');
39
+ if (mode !== 'desktop') return;
40
+ // Passed as an argument, never interpolated into the script: a command name cannot inject.
41
+ if (platform === 'darwin')
42
+ run('osascript', [
43
+ '-e',
44
+ 'on run argv\ndisplay notification (item 1 of argv) with title "Salaam"\nend run',
45
+ message,
46
+ ]);
47
+ else if (platform === 'linux') run('notify-send', ['Salaam', message]);
48
+ }
49
+ function detached(cmd: string, args: string[]): void {
50
+ try {
51
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
52
+ child.on('error', () => {});
53
+ child.unref();
54
+ } catch {
55
+ /* the bell already rang */
56
+ }
57
+ }
58
+
59
+ export interface JobRow {
60
+ id: string;
61
+ command: string;
62
+ state: string;
63
+ exit_code: number | null;
64
+ started_at: string;
65
+ }
66
+ export function jobsText(tasks: JobRow[], outputs: { output: string }[]): string {
67
+ if (!tasks.length)
68
+ return 'No background commands in this session.\nThe model starts one with salaam_local background: true.';
69
+ return tasks
70
+ .map((t, i) => {
71
+ const state = t.state === 'exited' ? `exited ${String(t.exit_code)}` : t.state;
72
+ const tail = outputs[i]?.output.split('\n').slice(-12).join('\n').trimEnd();
73
+ return `${t.id} ${state} ${t.command}${tail ? '\n' + tail.replace(/^/gm, ' │ ') : ''}`;
74
+ })
75
+ .join('\n\n')
76
+ .concat('\n\n/jobs stop <id> stops one.');
77
+ }
@@ -0,0 +1,76 @@
1
+ import { relativeTime } from './session-pick.js';
2
+ import type { CheckpointChange, CheckpointHunk } from './turn-checkpoints.js';
3
+ import type { ChangeReview, RewindReview } from './turn-rewind.js';
4
+
5
+ /** The Changes tab of a local coding session: one file selected, optionally one of its hunks. */
6
+ export interface ReviewView {
7
+ data: ChangeReview | null;
8
+ file: number;
9
+ hunks?: CheckpointHunk[];
10
+ /** -1 means the whole file. */
11
+ hunk: number;
12
+ }
13
+
14
+ const SHOWN = 12;
15
+
16
+ /** Background commands may keep writing while a review or rewind is open (operator ruling). */
17
+ export function backgroundNote(running: number | undefined): string | null {
18
+ if (!running) return null;
19
+ return running === 1
20
+ ? '1 background task running; its writes may change files. /jobs shows it.'
21
+ : `${running} background tasks running; their writes may change files. /jobs shows them.`;
22
+ }
23
+
24
+ export function changeCounts(change: CheckpointChange): string {
25
+ return change.added === null ? 'binary' : `+${change.added} −${change.removed}`;
26
+ }
27
+
28
+ /** What one confirm of a rewind does, in words. `changes` are from the folder now to the
29
+ * checkpoint, so a file the checkpoint has and the folder lacks is brought back. */
30
+ export function rewindSummary(review: RewindReview): string {
31
+ const lines: string[] = [];
32
+ if (review.mode === 'conversation') lines.push('Files are not changed.');
33
+ else if (!review.changes.length) lines.push('Files already match the checkpoint.');
34
+ else {
35
+ lines.push(
36
+ `Files: ${review.changes.length} to put back as they were before turn ${review.turn}.`,
37
+ );
38
+ for (const change of review.changes.slice(0, SHOWN))
39
+ lines.push(
40
+ ' ' +
41
+ (change.status === 'modified'
42
+ ? 'restore '
43
+ : change.status === 'added'
44
+ ? 'bring back '
45
+ : 'remove ') +
46
+ change.path +
47
+ ` (${changeCounts(change)})`,
48
+ );
49
+ if (review.changes.length > SHOWN)
50
+ lines.push(` … and ${review.changes.length - SHOWN} more files`);
51
+ }
52
+ lines.push(
53
+ review.mode === 'files'
54
+ ? 'The conversation is kept as it is.'
55
+ : `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.`,
56
+ );
57
+ lines.push('Commands and anything outside this folder are not undone.');
58
+ const note = backgroundNote(review.background_running);
59
+ if (note) lines.push(note);
60
+ return lines.join('\n');
61
+ }
62
+
63
+ /** /rewind's turn list: newest first, with how long ago each turn started. */
64
+ export function rewindItems(
65
+ points: readonly { attempt_id: string; turn: number; text: string; at: string }[],
66
+ now = Date.now(),
67
+ ) {
68
+ return points
69
+ .slice()
70
+ .reverse()
71
+ .map((p) => ({
72
+ value: p.attempt_id,
73
+ label: `Turn ${p.turn} · ${p.text.split('\n')[0]!.slice(0, 80)}`,
74
+ description: relativeTime(p.at, now),
75
+ }));
76
+ }