@superjackfruitlabs/salaam 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. package/src/web-search.ts +402 -0
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
+ }
@@ -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
+ }