@superjackfruitlabs/salaam 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/src/tui-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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { matchesKey } from '@earendil-works/pi-tui';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stopping and steering a running turn in an interactive coding session (operator plan
|
|
5
|
+
* 2026-10-31, Phase 2c). Esc interrupts the running turn, Enter steers it with what you typed, and
|
|
6
|
+
* Tab queues the text for after the turn. Other sessions, such as strict board work, keep plain
|
|
7
|
+
* submit and an explicit /cancel.
|
|
8
|
+
*/
|
|
9
|
+
export type TurnKeyAction = 'interrupt' | 'steer' | 'queue' | 'submit' | null;
|
|
10
|
+
|
|
11
|
+
interface TurnSnapshot {
|
|
12
|
+
capabilities: string[];
|
|
13
|
+
attempts: { id: string; submission_id: string; state: string }[];
|
|
14
|
+
submissions: { id: string; text: string; state: string }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Interactive local coding: the desk-side mode, not admitted or strict work. */
|
|
18
|
+
export function interactiveCoding(snapshot: TurnSnapshot | undefined): boolean {
|
|
19
|
+
return !!snapshot?.capabilities.includes('local-coding-reviewed');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The turn the model is working on now, if any. */
|
|
23
|
+
export function runningAttempt(snapshot: TurnSnapshot | undefined): string | undefined {
|
|
24
|
+
return snapshot?.attempts.find((a) => a.state === 'running')?.id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function turnKeyAction(input: {
|
|
28
|
+
key: 'escape' | 'enter' | 'tab';
|
|
29
|
+
text: string;
|
|
30
|
+
snapshot: TurnSnapshot | undefined;
|
|
31
|
+
modal: boolean;
|
|
32
|
+
autocomplete: boolean;
|
|
33
|
+
}): TurnKeyAction {
|
|
34
|
+
const { key, text, snapshot } = input;
|
|
35
|
+
if (input.modal || input.autocomplete) return key === 'enter' ? 'submit' : null;
|
|
36
|
+
const live = interactiveCoding(snapshot) && runningAttempt(snapshot) !== undefined;
|
|
37
|
+
const words = text.trim() !== '' && !text.trimStart().startsWith('/');
|
|
38
|
+
if (key === 'escape') return live ? 'interrupt' : null;
|
|
39
|
+
if (key === 'tab') return live && words ? 'queue' : null;
|
|
40
|
+
return live && words ? 'steer' : 'submit';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The key the TUI saw, as one of the three this module decides, or undefined for any other. */
|
|
44
|
+
export function turnKey(data: string): 'escape' | 'enter' | 'tab' | undefined {
|
|
45
|
+
return matchesKey(data, 'escape')
|
|
46
|
+
? 'escape'
|
|
47
|
+
: matchesKey(data, 'enter')
|
|
48
|
+
? 'enter'
|
|
49
|
+
: matchesKey(data, 'tab')
|
|
50
|
+
? 'tab'
|
|
51
|
+
: undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What /cancel offers, the running turn first, then work queued behind it. */
|
|
55
|
+
export function cancelChoices(snapshot: TurnSnapshot): { id: string; label: string }[] {
|
|
56
|
+
const order = ['running', 'dispatching', 'accepted'];
|
|
57
|
+
return snapshot.attempts
|
|
58
|
+
.filter((a) => order.includes(a.state))
|
|
59
|
+
.sort((a, b) => order.indexOf(a.state) - order.indexOf(b.state))
|
|
60
|
+
.map((a) => ({
|
|
61
|
+
id: a.id,
|
|
62
|
+
label: (a.state === 'accepted' ? 'queued' : a.state) + ' · ' + a.id.slice(0, 8),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Instructions waiting behind the running turn, with their text, so they can be edited or removed. */
|
|
67
|
+
export function queuedItems(
|
|
68
|
+
snapshot: TurnSnapshot,
|
|
69
|
+
): { attempt: string; submission: string; text: string }[] {
|
|
70
|
+
return snapshot.attempts
|
|
71
|
+
.filter((a) => a.state === 'accepted')
|
|
72
|
+
.map((a) => ({
|
|
73
|
+
attempt: a.id,
|
|
74
|
+
submission: a.submission_id,
|
|
75
|
+
text: snapshot.submissions.find((s) => s.id === a.submission_id)?.text ?? '',
|
|
76
|
+
}));
|
|
77
|
+
}
|