@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.
- package/CHANGELOG.md +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- 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/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -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/usage.js +1 -1
- 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 +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -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/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- 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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
'
|
|
384
|
+
'Approve?',
|
|
283
385
|
[
|
|
284
|
-
...
|
|
386
|
+
...body.slice(this.offset, this.offset + this.pageSize),
|
|
285
387
|
'',
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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('
|
|
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, '
|
|
327
|
-
this.
|
|
328
|
-
else if (matchesKey(data, '
|
|
329
|
-
this.
|
|
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
|
+
}
|