@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.ts CHANGED
@@ -11,9 +11,19 @@ import {
11
11
  type OverlayHandle,
12
12
  type KeyId,
13
13
  } from '@earendil-works/pi-tui';
14
+ import { extractMentions, isSensitivePath } from './mentions.js';
15
+ import { MentionAutocompleteProvider } from './tui-mentions.js';
14
16
  import { execFile, spawn, type ChildProcess } from 'node:child_process';
15
17
  import { promisify } from 'node:util';
16
18
  import { homedir } from 'node:os';
19
+ import {
20
+ cancelChoices,
21
+ interactiveCoding,
22
+ queuedItems,
23
+ runningAttempt,
24
+ turnKey,
25
+ turnKeyAction,
26
+ } from './tui-turn-keys.js';
17
27
  import { randomUUID } from 'node:crypto';
18
28
  import { join, resolve, basename } from 'node:path';
19
29
  import {
@@ -35,26 +45,75 @@ import {
35
45
  providerRoute,
36
46
  type ProviderRoute,
37
47
  } from './provider-config.js';
38
- import { SLASH_COMMANDS, SLASH_COMPLETIONS, parseSlashCommand, slashHelp } from './tui-commands.js';
48
+ import {
49
+ INIT_INSTRUCTION,
50
+ SLASH_COMMANDS,
51
+ SLASH_COMPLETIONS,
52
+ parseSlashCommand,
53
+ slashHelp,
54
+ } from './tui-commands.js';
55
+ import {
56
+ branchInstruction,
57
+ commitInstruction,
58
+ currentBranch,
59
+ defaultBase,
60
+ forgeTool,
61
+ loadGitPrefs,
62
+ prInstruction,
63
+ validBranchName,
64
+ } from './git-helpers.js';
39
65
  import { conversationExport, writeConversationExport } from './tui-export.js';
66
+ import { describeHooks } from './hooks.js';
67
+ import {
68
+ expandCustomCommand,
69
+ loadCustomCommands,
70
+ matchCustomCommand,
71
+ type CustomCommand,
72
+ } from './custom-commands.js';
40
73
  import { credentialPath } from './credentials.js';
41
74
  import { call } from './transport.js';
75
+ import { notificationFor, notify, notifyMode, jobsText, type JobRow } from './tui-notify.js';
76
+ import type { DurableEvent } from './protocol.js';
42
77
  import { OperatorClient } from './tui-client.js';
43
78
  import { SalaamTheme, safeText, shortId, type ThemeName } from './tui-theme.js';
44
79
  import {
45
80
  Dynamic,
46
81
  DocumentView,
47
82
  header,
83
+ modelLabel,
48
84
  tabs,
49
85
  status,
50
86
  sidebar,
51
87
  type ViewModel,
52
88
  type ViewTab,
53
89
  type DiffPage,
90
+ modeLabel,
54
91
  } from './tui-view.js';
55
- import { ChoiceModal, FormModal, ConfirmModal, ApprovalModal, TextModal } from './tui-modals.js';
92
+ import { nextMode } from './autonomy.js';
93
+ import {
94
+ ChoiceModal,
95
+ FormModal,
96
+ ConfirmModal,
97
+ ApprovalModal,
98
+ approvalReason,
99
+ onceOnlyReason,
100
+ TextModal,
101
+ } from './tui-modals.js';
56
102
  import type { Approval } from './protocol.js';
57
103
  import { commandTerminal } from './coding.js';
104
+ import { rewindItems, rewindSummary } from './tui-review.js';
105
+ import { classifierItems, classifierNotice, type ClassifierView } from './tui-classifier.js';
106
+ import { GLOBAL_KEYS, REVIEW_KEYS, keySheet, type GlobalAction } from './tui-keymap.js';
107
+ import { HerdrReporter, statusEnabled, terminalState, titleFor } from './terminal-status.js';
108
+ import {
109
+ ComposerHistory,
110
+ ctrlCAction,
111
+ historyPath,
112
+ searchHistory,
113
+ searchPrompt,
114
+ shellPassthrough,
115
+ } from './tui-composer.js';
116
+ import type { RewindMode } from './turn-rewind.js';
58
117
 
59
118
  export interface TuiOptions {
60
119
  state: string;
@@ -65,6 +124,10 @@ export interface TuiOptions {
65
124
  cwd?: string;
66
125
  codingRoot?: string;
67
126
  codingSocket?: string;
127
+ /** Your own custom commands folder; defaults to ~/.config/salaam/commands. */
128
+ commandsDir?: string;
129
+ /** `salaam code`: open this folder directly (step 0, 2026-10-31). */
130
+ start?: import('./session-pick.js').CodeStart;
68
131
  }
69
132
  /** Frames are atomic. A slow terminal gets one fresh full redraw after drain. */
70
133
  export class SalaamTerminal extends ProcessTerminal {
@@ -153,6 +216,12 @@ export class SalaamApp {
153
216
  private lastSession?: string;
154
217
  private lastRevision?: number;
155
218
  private readonly historyKeys = new Set<string>();
219
+ /** Messages this user sent, kept across sessions without secret-looking lines (Ctrl+R searches it). */
220
+ private readonly sent = new ComposerHistory(historyPath());
221
+ /** When Ctrl+C was last pressed on an empty composer; a second press within 2 s leaves. */
222
+ private ctrlCArmed?: number;
223
+ /** Reverse history search in progress: the query, the matching entry and the draft it replaces. */
224
+ private search?: { query: string; index: number; draft: string };
156
225
  private readonly removeInput: () => void;
157
226
  constructor(readonly options: TuiOptions) {
158
227
  this.client = new OperatorClient(options.state);
@@ -186,8 +255,9 @@ export class SalaamApp {
186
255
  );
187
256
  // The app journals the exact expanded input before clearing it. The library's submit path trims and clears first.
188
257
  this.editor.disableSubmit = true;
258
+ for (const entry of this.sent.entries) this.editor.addToHistory(entry);
189
259
  this.editor.setAutocompleteProvider(
190
- new CombinedAutocompleteProvider([...SLASH_COMPLETIONS], options.cwd ?? process.cwd(), null),
260
+ new MentionAutocompleteProvider([...SLASH_COMPLETIONS], options.cwd ?? process.cwd()),
191
261
  );
192
262
  this.editor.onChange = () => {
193
263
  const text = this.editor.getExpandedText(),
@@ -197,6 +267,8 @@ export class SalaamApp {
197
267
  return;
198
268
  }
199
269
  if (this.client.snapshot) this.drafts.set(this.client.snapshot.id, clean);
270
+ // Starting a command reloads this workspace's custom commands into the completion list.
271
+ if (clean === '/') this.refreshCommandCompletions();
200
272
  this.tui.requestRender();
201
273
  };
202
274
  this.document = new DocumentView(this.view);
@@ -255,6 +327,10 @@ export class SalaamApp {
255
327
  this.done = new Promise((resolve) => (this.finish = resolve));
256
328
  this.removeInput = this.tui.addInputListener((data) => this.input(data));
257
329
  this.client.on('change', () => this.changed());
330
+ this.client.on('durable', (event: DurableEvent) => {
331
+ const message = notificationFor(event);
332
+ if (message) notify(message, notifyMode());
333
+ });
258
334
  }
259
335
  private contentWidth(): number {
260
336
  return Math.max(
@@ -262,8 +338,27 @@ export class SalaamApp {
262
338
  this.terminal.columns - (this.rail && this.terminal.columns >= 104 ? 31 : 0),
263
339
  );
264
340
  }
341
+ // Trial polish C: the window title and Herdr say working / needs you / idle.
342
+ private readonly statusOn = statusEnabled();
343
+ private herdr = new HerdrReporter();
344
+ private lastTitle?: string;
345
+ private reportStatus(): void {
346
+ if (!this.statusOn) return;
347
+ const snapshot = this.client.snapshot;
348
+ const state = terminalState(snapshot);
349
+ const name = snapshot
350
+ ? (this.client.names[snapshot.id] ?? (snapshot.cwd ? basename(snapshot.cwd) : undefined))
351
+ : undefined;
352
+ const title = titleFor(state, name);
353
+ if (title !== this.lastTitle) {
354
+ this.lastTitle = title;
355
+ this.terminal.setTitle(title);
356
+ }
357
+ this.herdr.report(state, snapshot?.id);
358
+ }
265
359
  private changed(): void {
266
360
  const snapshot = this.client.snapshot;
361
+ this.reportStatus();
267
362
  if (snapshot) {
268
363
  if (snapshot.id !== this.lastSession) {
269
364
  if (this.lastSession) this.drafts.set(this.lastSession, this.editor.getExpandedText());
@@ -282,6 +377,11 @@ export class SalaamApp {
282
377
  this.historyKeys.add(submission.id);
283
378
  this.editor.addToHistory(submission.text);
284
379
  }
380
+ // Phase 2b: the status line shows this session's mode; reload it when the session changes.
381
+ if (this.client.snapshot?.id !== this.modeSession) {
382
+ this.modeSession = this.client.snapshot?.id;
383
+ void this.loadMode().catch(() => {});
384
+ }
285
385
  if (this.client.workspace?.revision !== this.lastRevision) {
286
386
  this.lastRevision = this.client.workspace?.revision;
287
387
  this.view.diff = undefined;
@@ -293,19 +393,73 @@ export class SalaamApp {
293
393
  this.tui.requestRender();
294
394
  }
295
395
  private input(data: string): { consume: true } | undefined {
296
- if (
297
- matchesKey(data, 'ctrl+c') ||
298
- (matchesKey(data, 'ctrl+d') && !this.editor.getExpandedText())
299
- ) {
396
+ if (this.search) return this.searchKey(data);
397
+ if (matchesKey(data, 'ctrl+c')) {
398
+ this.ctrlC();
399
+ return { consume: true };
400
+ }
401
+ this.ctrlCArmed = undefined;
402
+ if (matchesKey(data, 'ctrl+d') && !this.editor.getExpandedText()) {
300
403
  this.finish();
301
404
  return { consume: true };
302
405
  }
303
406
  if (this.modal) return;
407
+ if (matchesKey(data, 'ctrl+r')) {
408
+ this.startSearch();
409
+ return { consume: true };
410
+ }
304
411
  if (
305
412
  this.editor.isShowingAutocomplete() &&
306
413
  (matchesKey(data, 'enter') || matchesKey(data, 'tab') || matchesKey(data, 'escape'))
307
414
  )
308
415
  return;
416
+ if (data === '?' && this.editor.getExpandedText() === '') {
417
+ this.help();
418
+ return { consume: true };
419
+ }
420
+ // Esc stops the running turn, Enter steers it, Tab queues behind it (interactive coding only).
421
+ const key = turnKey(data);
422
+ const action = key
423
+ ? turnKeyAction({
424
+ key,
425
+ text: this.editor.getExpandedText(),
426
+ snapshot: this.client.snapshot,
427
+ modal: false,
428
+ autocomplete: false,
429
+ })
430
+ : null;
431
+ if (action === 'interrupt') {
432
+ const attempt = runningAttempt(this.client.snapshot);
433
+ if (attempt)
434
+ void this.execute(async () => {
435
+ await this.client.cancel(attempt);
436
+ this.client.report(
437
+ 'Stopped the turn. Edits already made stay; /undo reverts the last one. Your typed text is kept.',
438
+ );
439
+ });
440
+ return { consume: true };
441
+ }
442
+ if (action === 'steer' || action === 'queue') {
443
+ const text = this.editor.getExpandedText(),
444
+ session = this.client.snapshot?.id;
445
+ void this.execute(async () => {
446
+ if (action === 'steer') await this.client.steer(text);
447
+ else {
448
+ await this.client.submit(text);
449
+ const waiting = this.client.snapshot ? queuedItems(this.client.snapshot).length : 0;
450
+ this.client.report(
451
+ 'Queued for after this turn (' + waiting + ' waiting). /queue edits or removes them.',
452
+ );
453
+ }
454
+ this.editor.addToHistory(text);
455
+ this.sent.add(text);
456
+ if (this.client.snapshot?.id === session && this.editor.getExpandedText() === text) {
457
+ this.editor.setText('');
458
+ if (session) this.drafts.delete(session);
459
+ }
460
+ });
461
+ return { consume: true };
462
+ }
309
463
  if (matchesKey(data, 'enter')) {
310
464
  const text = this.editor.getExpandedText(),
311
465
  session = this.client.snapshot?.id;
@@ -321,24 +475,11 @@ export class SalaamApp {
321
475
  else submit();
322
476
  return { consume: true };
323
477
  }
324
- const keys: [KeyId, () => void][] = [
325
- ['ctrl+k', () => this.palette()],
326
- ['ctrl+p', () => void this.execute(() => this.sessions())],
327
- ['ctrl+n', () => void this.execute(() => this.newSession())],
328
- ['ctrl+o', () => this.approvals()],
329
- ['ctrl+g', () => this.setTab('changes')],
330
- ['ctrl+t', () => this.setTab('commands')],
331
- [
332
- 'ctrl+b',
333
- () => {
334
- this.rail = !this.rail;
335
- this.tui.requestRender(true);
336
- },
337
- ],
338
- ['ctrl+r', () => void this.execute(() => this.refresh())],
339
- ['ctrl+e', () => void this.execute(() => this.editPrompt())],
340
- ['f1', () => this.help()],
341
- ];
478
+ // Ctrl+K kills to the end of the line while the composer has text (readline); on an empty
479
+ // composer it opens the actions palette. F2 always opens it.
480
+ if (matchesKey(data, 'ctrl+k') && this.editor.getExpandedText() !== '') return;
481
+ const actions = this.globalActions();
482
+ const keys: [KeyId, () => void][] = GLOBAL_KEYS.map((k) => [k.key, () => actions[k.action]()]);
342
483
  for (const [key, action] of keys)
343
484
  if (matchesKey(data, key)) {
344
485
  if (this.view.busy) this.pendingAction = action;
@@ -352,6 +493,10 @@ export class SalaamApp {
352
493
  this.setTab(names[(names.indexOf(this.view.tab) + 1) % names.length]!);
353
494
  return { consume: true };
354
495
  }
496
+ if (this.view.tab === 'changes' && interactiveCoding(this.client.snapshot)) {
497
+ const handled = this.reviewKey(data);
498
+ if (handled) return handled;
499
+ }
355
500
  if (
356
501
  this.view.tab === 'changes' &&
357
502
  (matchesKey(data, 'alt+right') || matchesKey(data, 'alt+left'))
@@ -364,6 +509,30 @@ export class SalaamApp {
364
509
  }
365
510
  return;
366
511
  }
512
+ /** What each key in GLOBAL_KEYS does; the table owns the keys, this owns the actions. */
513
+ private globalActions(): Record<GlobalAction, () => void> {
514
+ return (this.actionTable ??= {
515
+ palette: () => this.palette(),
516
+ sessions: () => void this.execute(() => this.sessions()),
517
+ new: () => void this.execute(() => this.newSession()),
518
+ approvals: () => this.approvals(),
519
+ changes: () => this.setTab('changes'),
520
+ commands: () => this.setTab('commands'),
521
+ rail: () => {
522
+ this.rail = !this.rail;
523
+ this.tui.requestRender(true);
524
+ },
525
+ transcript: () => {
526
+ this.view.transcript = !this.view.transcript;
527
+ this.setTab('conversation');
528
+ },
529
+ refresh: () => void this.execute(() => this.refresh()),
530
+ editor: () => void this.execute(() => this.editPrompt()),
531
+ mode: () => void this.execute(() => this.cycleMode()),
532
+ help: () => this.help(),
533
+ });
534
+ }
535
+ private actionTable?: Record<GlobalAction, () => void>;
367
536
  private async execute(work: () => Promise<unknown>): Promise<void> {
368
537
  if (this.view.busy) {
369
538
  const session = this.client.snapshot?.id ?? this.options.session;
@@ -426,6 +595,7 @@ export class SalaamApp {
426
595
  description: string,
427
596
  label: string,
428
597
  confirm: () => Promise<unknown>,
598
+ confirmByDefault = false,
429
599
  ): void {
430
600
  if (this.terminal.rows < 18) {
431
601
  this.client.report('Expand the terminal to at least 18 rows to review this action.');
@@ -443,6 +613,7 @@ export class SalaamApp {
443
613
  this.closeModal();
444
614
  void this.execute(confirm);
445
615
  },
616
+ confirmByDefault,
446
617
  ),
447
618
  );
448
619
  }
@@ -476,25 +647,51 @@ export class SalaamApp {
476
647
  if (this.client.connection === 'offline')
477
648
  throw new Error('Service did not become available. Inspect sal service status.');
478
649
  }
650
+ /** Interactive sessions switch in place between turns; otherwise the choice opens a new session. */
651
+ private async applyRoute(route: import('./provider-config.js').ProviderRoute): Promise<void> {
652
+ if (this.client.snapshot?.provider_route && this.client.controlling) {
653
+ try {
654
+ await this.client.switchRoute(route);
655
+ this.client.notice = 'Now using ' + modelLabel(this.client) + ' in this session.';
656
+ return;
657
+ } catch (error) {
658
+ // A route that limits output differently cannot join this session. Keep the choice and
659
+ // open the new session with it, rather than leaving the old model selected.
660
+ if ((error as { code?: string }).code !== 'provider_output_cap_changed') throw error;
661
+ this.selectedRoute = route;
662
+ this.client.notice = `${route.model ?? route.provider} limits output differently from this session's model, so it starts in a new session. This session stays as it is.`;
663
+ await this.newSession();
664
+ return;
665
+ }
666
+ }
667
+ this.selectedRoute = route;
668
+ await this.newSession();
669
+ }
479
670
  private modelPicker(): void {
671
+ const configured = (this.client.snapshot?.provider_route ?? this.client.health?.provider_route)
672
+ ?.provider;
673
+ const live = PI_PROVIDERS.filter((p) => p.getModels().length);
480
674
  this.choice(
481
- 'Provider · applies to a new session',
675
+ this.client.snapshot ? 'Provider · switches this session' : 'Provider',
482
676
  [
677
+ // The configured provider first; the scripted demo last, never the default.
678
+ ...[
679
+ ...live.filter((p) => p.id === configured),
680
+ ...live.filter((p) => p.id !== configured),
681
+ ].map((p) => ({
682
+ value: p.id,
683
+ label: p.name,
684
+ description: p.id + (p.id === configured ? ' · current' : ''),
685
+ })),
483
686
  {
484
687
  value: 'offline',
485
688
  label: 'Offline demo',
486
689
  description: 'Scripted fixture, not an AI model',
487
690
  },
488
- ...PI_PROVIDERS.filter((p) => p.getModels().length).map((p) => ({
489
- value: p.id,
490
- label: p.name,
491
- description: p.id,
492
- })),
493
691
  ],
494
692
  (provider) => {
495
693
  if (provider === 'offline') {
496
- this.selectedRoute = providerRoute();
497
- void this.execute(() => this.newSession());
694
+ void this.execute(() => this.applyRoute(providerRoute()));
498
695
  return;
499
696
  }
500
697
  const catalog = PI_PROVIDERS.find((p) => p.id === provider)!;
@@ -505,14 +702,100 @@ export class SalaamApp {
505
702
  label: m.name,
506
703
  description: m.id + ' · ' + m.contextWindow + ' context tokens',
507
704
  })),
508
- (model) => {
509
- this.selectedRoute = providerRoute(provider, model);
510
- void this.execute(() => this.newSession());
511
- },
705
+ (model) => void this.execute(() => this.applyRoute(providerRoute(provider, model))),
512
706
  );
513
707
  },
514
708
  );
515
709
  }
710
+ private modeSession: string | undefined;
711
+ /** Phase 2b: the session's mode and full access, for the status line. */
712
+ private async loadMode(): Promise<void> {
713
+ if (!this.client.snapshot) {
714
+ this.view.mode = undefined;
715
+ return;
716
+ }
717
+ const current = await this.client.permissions();
718
+ this.view.mode =
719
+ current.configurable && current.coding
720
+ ? {
721
+ mode: current.policy.mode,
722
+ ...(current.policy.access ? { access: current.policy.access } : {}),
723
+ ...(current.command_sandbox ? { sandbox: current.command_sandbox } : {}),
724
+ }
725
+ : undefined;
726
+ this.tui.requestRender();
727
+ }
728
+ /** Shift+Tab: review → accept edits → auto. Full access and YOLO are kept as they are; each has
729
+ * its own command. */
730
+ private async cycleMode(): Promise<void> {
731
+ const session = this.client.snapshot?.id;
732
+ if (!session) throw new Error('Open a session first.');
733
+ const current = await this.client.permissions();
734
+ if (!current.configurable || !current.coding)
735
+ throw new Error('Modes are for local coding sessions. No session or files were changed.');
736
+ await this.client.setPermissions(session, current.policy.revision, {
737
+ mode: nextMode(current.policy.mode),
738
+ paths: [],
739
+ ...(current.policy.access ? { access: current.policy.access } : {}),
740
+ });
741
+ await this.loadMode();
742
+ }
743
+ private async setAccess(access: 'full' | 'yolo' | undefined): Promise<void> {
744
+ const session = this.client.snapshot!.id;
745
+ const current = await this.client.permissions();
746
+ await this.client.setPermissions(session, current.policy.revision, {
747
+ mode: current.policy.mode,
748
+ paths: current.policy.paths,
749
+ ...(access ? { access } : {}),
750
+ });
751
+ await this.loadMode();
752
+ }
753
+ /** Full access on (with a warning and a confirm) or off (at once). From YOLO it steps down at once. */
754
+ private async fullAccess(): Promise<void> {
755
+ const session = this.client.snapshot?.id;
756
+ if (!session) throw new Error('Open a session first.');
757
+ const current = await this.client.permissions();
758
+ if (!current.configurable || !current.coding)
759
+ throw new Error('Full access is for local coding sessions. Nothing was changed.');
760
+ if (current.policy.access === 'full') return this.setAccess(undefined);
761
+ if (current.full_access_allowed === false)
762
+ throw new Error(
763
+ 'The operator does not allow full access on this host. Commands stay in the OS sandbox.',
764
+ );
765
+ if (current.policy.access === 'yolo') return this.setAccess('full');
766
+ this.confirm(
767
+ 'Turn on full access?',
768
+ 'Later commands in this session run WITHOUT the OS sandbox: they can read and change any file your user can, and reach the network directly. They run without asking, except commands the danger check calls dangerous (force pushes, deletes, publishing, credentials, anything it cannot read), which still ask and say why. Run /full-access again to turn it off.',
769
+ 'turn on full access',
770
+ () => this.setAccess('full'),
771
+ );
772
+ }
773
+ /** YOLO on only after the person types "yolo"; off at once. Never in the Shift+Tab cycle. */
774
+ private async yolo(): Promise<void> {
775
+ const session = this.client.snapshot?.id;
776
+ if (!session) throw new Error('Open a session first.');
777
+ const current = await this.client.permissions();
778
+ if (!current.configurable || !current.coding)
779
+ throw new Error('YOLO is for local coding sessions. Nothing was changed.');
780
+ if (current.policy.access === 'yolo') return this.setAccess(undefined);
781
+ if (current.yolo_allowed === false)
782
+ throw new Error(
783
+ 'YOLO is not allowed for this session: it is only for your own desk sessions on a host whose operator allows it.',
784
+ );
785
+ this.taskInput(
786
+ 'Turn on YOLO? Type yolo to confirm',
787
+ '',
788
+ 'Type yolo',
789
+ 'NO approvals at all: every edit and command runs at once, without the OS sandbox, including force pushes, deletes and publishing. Each one is recorded. /yolo again turns it off.',
790
+ (value) => {
791
+ if (value.trim() !== 'yolo') {
792
+ this.client.report('YOLO stays off: the confirmation was not "yolo".');
793
+ return;
794
+ }
795
+ void this.execute(() => this.setAccess('yolo'));
796
+ },
797
+ );
798
+ }
516
799
  private showText(title: string, text: string): void {
517
800
  this.showModal(
518
801
  new TextModal(
@@ -536,7 +819,48 @@ export class SalaamApp {
536
819
  return;
537
820
  }
538
821
  if (command === 'help') {
539
- this.showText('Salaam commands', slashHelp(args || undefined));
822
+ const custom = args ? [] : this.customCommands();
823
+ const problems = args ? [] : this.customCommandProblems;
824
+ this.showText(
825
+ 'Salaam commands',
826
+ slashHelp(args || undefined) +
827
+ (custom.length
828
+ ? '\n\nCustom commands (write a prompt only):\n' +
829
+ custom
830
+ .map(
831
+ (c) =>
832
+ `/${c.name}${c.argumentHint ? ' ' + c.argumentHint : ''} — ${c.description} [${c.scope}]`,
833
+ )
834
+ .join('\n')
835
+ : '') +
836
+ (problems.length ? '\n\nSkipped command files:\n' + problems.join('\n') : ''),
837
+ );
838
+ return;
839
+ }
840
+ if (command === 'hooks') {
841
+ if (args === 'revoke') {
842
+ await this.client.revokeHooks();
843
+ return;
844
+ }
845
+ const { sources } = await this.client.hooks();
846
+ if (args === 'trust') {
847
+ const repo = sources.find((s) => s.scope === 'repo');
848
+ if (!repo?.digest) throw new Error('This workspace has no readable .salaam/hooks.json.');
849
+ if (repo.trusted) {
850
+ this.showText('Hooks', 'This workspace’s hooks are already trusted as they are.');
851
+ return;
852
+ }
853
+ this.confirm(
854
+ 'Trust this workspace’s hooks?',
855
+ describeHooks([repo]) +
856
+ '\n\nThese commands will run on your machine, in this session’s sandbox, whenever their event happens. Trust covers this exact content; any change needs trusting again.',
857
+ 'Trust these hooks',
858
+ () => this.client.trustHooks(repo.digest!),
859
+ );
860
+ return;
861
+ }
862
+ if (args) throw new Error('Use /hooks, /hooks trust or /hooks revoke.');
863
+ this.showText('Hooks', describeHooks(sources));
540
864
  return;
541
865
  }
542
866
  if (command === 'keys') {
@@ -549,10 +873,8 @@ export class SalaamApp {
549
873
  return;
550
874
  }
551
875
  const slash = args.indexOf('/');
552
- if (slash <= 0)
553
- throw new Error('Use /model provider/model. The choice creates a new session.');
554
- this.selectedRoute = providerRoute(args.slice(0, slash), args.slice(slash + 1));
555
- await this.newSession();
876
+ if (slash <= 0) throw new Error('Use /model provider/model.');
877
+ await this.applyRoute(providerRoute(args.slice(0, slash), args.slice(slash + 1)));
556
878
  return;
557
879
  }
558
880
  if (command === 'provider') {
@@ -561,8 +883,7 @@ export class SalaamApp {
561
883
  return;
562
884
  }
563
885
  if (args === 'offline') {
564
- this.selectedRoute = providerRoute();
565
- await this.newSession();
886
+ await this.applyRoute(providerRoute());
566
887
  return;
567
888
  }
568
889
  const provider = PI_PROVIDERS.find((p) => p.id === args);
@@ -571,10 +892,7 @@ export class SalaamApp {
571
892
  this.choice(
572
893
  'Model · ' + provider.name,
573
894
  provider.getModels().map((m) => ({ value: m.id, label: m.name, description: m.id })),
574
- (model) => {
575
- this.selectedRoute = providerRoute(args, model);
576
- void this.execute(() => this.newSession());
577
- },
895
+ (model) => void this.execute(() => this.applyRoute(providerRoute(args, model))),
578
896
  );
579
897
  return;
580
898
  }
@@ -602,7 +920,7 @@ export class SalaamApp {
602
920
  [
603
921
  { value: 'permissions', label: 'Permissions · inspect, review or scoped edits' },
604
922
  { value: 'progression', label: 'Progression · manual, bounded tasks or schedules' },
605
- { value: 'model', label: 'Provider and model (new session)' },
923
+ { value: 'model', label: 'Provider and model' },
606
924
  { value: 'theme', label: 'Switch Night / Paper' },
607
925
  { value: 'rail', label: 'Toggle workspace rail' },
608
926
  { value: 'keys', label: 'Keyboard shortcuts' },
@@ -639,6 +957,10 @@ export class SalaamApp {
639
957
  return;
640
958
  }
641
959
  if (!snapshot) throw new Error('Choose a session with /sessions or /new first.');
960
+ if (command === 'rewind') {
961
+ await this.rewind(args);
962
+ return;
963
+ }
642
964
  if (command === 'undo') {
643
965
  if (args === 'reconcile') {
644
966
  await this.client.reconcileUndo();
@@ -682,6 +1004,36 @@ export class SalaamApp {
682
1004
  );
683
1005
  return;
684
1006
  }
1007
+ if (command === 'commit' || command === 'branch' || command === 'pr') {
1008
+ if (!snapshot.capabilities.includes('local-coding-reviewed'))
1009
+ throw new Error(`/${command} runs git. Start a coding session with /coding first.`);
1010
+ const root = snapshot.cwd;
1011
+ let instruction: string;
1012
+ if (command === 'commit')
1013
+ instruction = commitInstruction({ ...loadGitPrefs(), session: snapshot.id });
1014
+ else if (command === 'branch') {
1015
+ if (!args) throw new Error('Usage: /branch <name>');
1016
+ instruction = branchInstruction(args);
1017
+ } else {
1018
+ const branch = currentBranch(root),
1019
+ base = args || defaultBase(root);
1020
+ if (!branch) throw new Error('/pr needs a checked-out branch, not a detached HEAD.');
1021
+ if (branch === base)
1022
+ throw new Error(`You are on ${base}. Create a branch first with /branch <name>.`);
1023
+ if (!validBranchName(base)) throw new Error(`"${base}" is not a valid branch name.`);
1024
+ instruction = prInstruction({ tool: forgeTool(root), branch, base });
1025
+ }
1026
+ await this.client.submit(instruction);
1027
+ this.setTab('conversation');
1028
+ return;
1029
+ }
1030
+ if (command === 'init') {
1031
+ if (!snapshot.capabilities.includes('local-coding-reviewed'))
1032
+ throw new Error('/init writes a file. Start a coding session with /coding first.');
1033
+ await this.client.submit(INIT_INSTRUCTION);
1034
+ this.setTab('conversation');
1035
+ return;
1036
+ }
685
1037
  if (command === 'compact') {
686
1038
  await this.client.compact();
687
1039
  return;
@@ -695,7 +1047,9 @@ export class SalaamApp {
695
1047
  return;
696
1048
  }
697
1049
  if (command === 'diff') {
698
- if (snapshot.capabilities.includes('local-workspace-read')) {
1050
+ // Coding sessions review their diff file by file in Changes (Phase 3a).
1051
+ if (snapshot.capabilities.includes('local-coding-reviewed')) this.setTab('changes');
1052
+ else if (snapshot.capabilities.includes('local-workspace-read')) {
699
1053
  const diff = await call<{ text: string }>(
700
1054
  this.client.socket,
701
1055
  `/v1/sessions/${snapshot.id}/local-diff`,
@@ -712,10 +1066,59 @@ export class SalaamApp {
712
1066
  this.taskActions();
713
1067
  return;
714
1068
  }
715
- if (command === 'cancel') {
716
- const active = snapshot.attempts.filter((a) =>
717
- ['accepted', 'dispatching', 'running'].includes(a.state),
1069
+ if (command === 'classifier') {
1070
+ const path = `/v1/sessions/${snapshot.id}/danger-classifier`;
1071
+ const set = async (choice: string) => {
1072
+ const v = await call<ClassifierView>(this.client.socket, path, {
1073
+ ...this.client.actor(),
1074
+ choice,
1075
+ });
1076
+ this.client.notice = classifierNotice(v);
1077
+ this.tui.requestRender();
1078
+ };
1079
+ const want = args.trim();
1080
+ if (want === 'on') {
1081
+ const v = await call<ClassifierView>(this.client.socket, path, {
1082
+ ...this.client.actor(),
1083
+ enabled: true,
1084
+ });
1085
+ this.client.notice = classifierNotice(v);
1086
+ return;
1087
+ }
1088
+ if (want) {
1089
+ await set(want);
1090
+ return;
1091
+ }
1092
+ const v = await call<ClassifierView>(this.client.socket, path);
1093
+ const items = classifierItems(v);
1094
+ if (!items.length) {
1095
+ this.client.notice = classifierNotice(v);
1096
+ return;
1097
+ }
1098
+ this.choice('Command reviewer · ' + classifierNotice(v).split('.')[0], items, (choice) => {
1099
+ void this.execute(() => set(choice));
1100
+ });
1101
+ return;
1102
+ }
1103
+ if (command === 'jobs') {
1104
+ const base = `/v1/sessions/${snapshot.id}/local-tasks`;
1105
+ const stop = /^stop\s+(\S+)$/.exec(args.trim());
1106
+ if (stop) {
1107
+ await call(this.client.socket, `${base}/${stop[1]}/stop`, {});
1108
+ this.client.notice = 'Stopping background command ' + stop[1];
1109
+ return;
1110
+ }
1111
+ const { tasks } = await call<{ tasks: JobRow[] }>(this.client.socket, base);
1112
+ const outputs = await Promise.all(
1113
+ tasks
1114
+ .slice(0, 4)
1115
+ .map((t) => call<{ output: string }>(this.client.socket, `${base}/${t.id}`)),
718
1116
  );
1117
+ this.showText('Background commands', jobsText(tasks, outputs));
1118
+ return;
1119
+ }
1120
+ if (command === 'cancel') {
1121
+ const active = cancelChoices(snapshot);
719
1122
  if (!active.length) {
720
1123
  this.client.report('No active or queued instruction to cancel.');
721
1124
  return;
@@ -723,15 +1126,18 @@ export class SalaamApp {
723
1126
  const cancel = (id: string) =>
724
1127
  this.confirm(
725
1128
  'Cancel instruction',
726
- 'Cancel attempt ' + id + '? /exit would leave it running.',
1129
+ 'Cancel attempt ' +
1130
+ shortId(id) +
1131
+ '? Your typed text is kept. /exit would leave it running.',
727
1132
  'Cancel work',
728
1133
  () => this.client.cancel(id),
1134
+ true,
729
1135
  );
730
1136
  if (active.length === 1) cancel(active[0]!.id);
731
1137
  else
732
1138
  this.choice(
733
1139
  'Cancel an instruction',
734
- active.map((a) => ({ value: a.id, label: a.state + ' · ' + shortId(a.id) })),
1140
+ active.map((a) => ({ value: a.id, label: a.label })),
735
1141
  cancel,
736
1142
  );
737
1143
  return;
@@ -746,15 +1152,29 @@ export class SalaamApp {
746
1152
  await this.client.submit(args);
747
1153
  return;
748
1154
  }
749
- this.showText(
750
- 'Durable instruction queue',
751
- JSON.stringify(
752
- snapshot.submissions.filter((s) =>
753
- ['accepted', 'dispatching', 'running'].includes(s.state),
754
- ),
755
- null,
756
- 2,
757
- ),
1155
+ const queued = queuedItems(snapshot);
1156
+ if (!queued.length) {
1157
+ this.client.report('Nothing is queued. Tab queues text for after the running turn.');
1158
+ return;
1159
+ }
1160
+ this.choice(
1161
+ 'Queued instructions',
1162
+ queued.map((q) => ({ value: q.attempt, label: q.text.slice(0, 80) })),
1163
+ (attempt) => {
1164
+ const item = queued.find((q) => q.attempt === attempt)!;
1165
+ this.choice(
1166
+ 'Queued instruction',
1167
+ [
1168
+ { value: 'edit', label: 'Edit', description: 'Move it back into the input' },
1169
+ { value: 'remove', label: 'Remove', description: 'Cancel it before it runs' },
1170
+ ],
1171
+ (choice) =>
1172
+ void this.execute(async () => {
1173
+ await this.client.cancel(item.attempt);
1174
+ if (choice === 'edit') this.editor.setText(item.text);
1175
+ }),
1176
+ );
1177
+ },
758
1178
  );
759
1179
  return;
760
1180
  }
@@ -782,6 +1202,18 @@ export class SalaamApp {
782
1202
  );
783
1203
  return;
784
1204
  }
1205
+ if (command === 'mode') {
1206
+ await this.cycleMode();
1207
+ return;
1208
+ }
1209
+ if (command === 'full-access') {
1210
+ await this.fullAccess();
1211
+ return;
1212
+ }
1213
+ if (command === 'yolo') {
1214
+ await this.yolo();
1215
+ return;
1216
+ }
785
1217
  if (command === 'permissions') {
786
1218
  await this.permissionControls();
787
1219
  return;
@@ -911,21 +1343,21 @@ export class SalaamApp {
911
1343
  const current = snapshot.provider_route;
912
1344
  if (!current || current.provider === 'salaam-proof')
913
1345
  throw new Error('Choose a live provider/model first with /model.');
914
- const choose = (level: string) => {
915
- this.selectedRoute = providerRoute(
916
- current.provider,
917
- current.model,
918
- current.max_tokens,
919
- current.workspace_id,
920
- current.connection,
921
- level as import('./provider-config.js').ReasoningLevel,
1346
+ const choose = (level: string) =>
1347
+ this.applyRoute(
1348
+ providerRoute(
1349
+ current.provider,
1350
+ current.model,
1351
+ current.max_tokens,
1352
+ current.workspace_id,
1353
+ current.connection,
1354
+ level as import('./provider-config.js').ReasoningLevel,
1355
+ ),
922
1356
  );
923
- return this.newSession(snapshot.capabilities.includes('local-coding-reviewed'));
924
- };
925
1357
  if (args) await choose(args);
926
1358
  else
927
1359
  this.choice(
928
- 'Reasoning · new session · current ' + (current.reasoning ?? 'automatic low'),
1360
+ 'Reasoning · current ' + (current.reasoning ?? 'automatic low'),
929
1361
  REASONING_LEVELS.map((level) => ({ value: level, label: level })),
930
1362
  (level) => void this.execute(() => choose(level)),
931
1363
  );
@@ -974,9 +1406,7 @@ export class SalaamApp {
974
1406
  await this.startService();
975
1407
  this.showModal(
976
1408
  new FormModal(
977
- (localCoding
978
- ? 'New coding session · reviewed edits and unsandboxed commands · '
979
- : 'New session · ') +
1409
+ (localCoding ? 'New coding session · reviewed edits and commands · ' : 'New session · ') +
980
1410
  (this.selectedRoute?.model ?? this.client.health?.provider_route.model ?? 'default'),
981
1411
  this.options.cwd ?? this.client.snapshot?.cwd ?? process.cwd(),
982
1412
  this.view.theme,
@@ -994,19 +1424,28 @@ export class SalaamApp {
994
1424
  );
995
1425
  }
996
1426
  private async sessions(): Promise<void> {
1427
+ const { pickerOrder, sessionDescription } = await import('./session-pick.js');
997
1428
  await this.client.list();
1429
+ const now = Date.now();
998
1430
  this.choice(
999
1431
  'Your Salaam sessions',
1000
1432
  [
1001
1433
  { value: 'new', label: '+ New session', description: 'Start a fresh workspace' },
1002
- ...this.client.sessions.map((session) => ({
1003
- value: session.id,
1004
- label:
1005
- (this.client.names[session.id] ?? basename(session.cwd)) + ' ' + shortId(session.id),
1006
- description: session.state + ' · ' + session.cwd,
1007
- })),
1434
+ ...pickerOrder(this.client.sessions, resolve(this.options.cwd ?? process.cwd())).map(
1435
+ (session) => ({
1436
+ value: session.id,
1437
+ label:
1438
+ (this.client.names[session.id] ?? basename(session.cwd)) + ' ' + shortId(session.id),
1439
+ description: sessionDescription(session, now),
1440
+ }),
1441
+ ),
1008
1442
  ],
1009
1443
  (value) => {
1444
+ if (value === 'transcript') {
1445
+ this.view.transcript = !this.view.transcript;
1446
+ this.setTab('conversation');
1447
+ return;
1448
+ }
1010
1449
  if (value === 'model') this.modelPicker();
1011
1450
  else if (value === 'help') void this.execute(() => this.runSlashCommand('help'));
1012
1451
  else if (value === 'coding') void this.execute(() => this.newSession(true));
@@ -1022,11 +1461,71 @@ export class SalaamApp {
1022
1461
  },
1023
1462
  );
1024
1463
  }
1464
+ private commandCache?: { root: string; at: number; commands: CustomCommand[] };
1465
+ private customCommandProblems: string[] = [];
1466
+ /** This workspace's and your own custom commands, reread at most every two seconds. */
1467
+ customCommands(): CustomCommand[] {
1468
+ const root = this.client.snapshot?.cwd ?? this.options.cwd ?? process.cwd();
1469
+ const cached = this.commandCache;
1470
+ if (cached && cached.root === root && Date.now() - cached.at < 2000) return cached.commands;
1471
+ const { commands, problems } = loadCustomCommands(root, this.options.commandsDir);
1472
+ this.customCommandProblems = problems;
1473
+ this.commandCache = { root, at: Date.now(), commands };
1474
+ return commands;
1475
+ }
1476
+ private refreshCommandCompletions(): void {
1477
+ const root = this.client.snapshot?.cwd ?? this.options.cwd ?? process.cwd();
1478
+ this.editor.setAutocompleteProvider(
1479
+ new MentionAutocompleteProvider(
1480
+ [
1481
+ ...SLASH_COMPLETIONS,
1482
+ ...this.customCommands().map((c) => ({
1483
+ name: c.name,
1484
+ description: `${c.description} [${c.scope}]`,
1485
+ })),
1486
+ ],
1487
+ root,
1488
+ ),
1489
+ );
1490
+ }
1025
1491
  private async submit(): Promise<void> {
1026
1492
  const text = this.editor.getExpandedText(),
1027
1493
  session = this.client.snapshot?.id,
1028
1494
  continuation = this.view.continuation;
1029
1495
  if (!text.trim()) return;
1496
+ const shell = shellPassthrough(text);
1497
+ if (shell) {
1498
+ if (!session) throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
1499
+ if (!interactiveCoding(this.client.snapshot))
1500
+ throw new Error('!command works in local coding sessions (salaam code).');
1501
+ await this.deliver(shell, session, continuation, false, text);
1502
+ return;
1503
+ }
1504
+ // A custom command only writes a prompt; built-in names never reach here (they are refused at
1505
+ // load), and the expanded prompt goes through every check an ordinary message does.
1506
+ const custom = text.trim().startsWith('/')
1507
+ ? matchCustomCommand(text, this.customCommands())
1508
+ : null;
1509
+ if (custom) {
1510
+ if (!session) throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
1511
+ const prompt = expandCustomCommand(custom.command, custom.args);
1512
+ if (Buffer.byteLength(prompt) > 65536)
1513
+ throw new Error(`/${custom.command.name} expands past 64 KiB. Shorten the command file.`);
1514
+ const secrets = extractMentions(prompt).filter(isSensitivePath);
1515
+ const send = (sensitive: boolean) =>
1516
+ this.deliver(prompt, session, continuation, sensitive, text);
1517
+ if (secrets.length) {
1518
+ this.confirm(
1519
+ 'Attach secret-looking files?',
1520
+ `/${custom.command.name} mentions ${secrets.map((s) => '@' + s).join(', ')}, which will be sent to the model in full.`,
1521
+ 'Attach and send',
1522
+ () => send(true),
1523
+ );
1524
+ return;
1525
+ }
1526
+ await send(false);
1527
+ return;
1528
+ }
1030
1529
  const command = parseSlashCommand(text);
1031
1530
  if (command) {
1032
1531
  await this.runSlashCommand(command.name, command.args);
@@ -1037,9 +1536,30 @@ export class SalaamApp {
1037
1536
  if (!session) throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
1038
1537
  if (Buffer.byteLength(text) > 65536)
1039
1538
  throw new Error('The composer accepts at most 64 KiB. Use an artifact for longer input.');
1040
- await this.client.submit(text, continuation);
1041
- this.editor.addToHistory(text);
1042
- if (this.client.snapshot?.id === session && this.editor.getExpandedText() === text) {
1539
+ const secrets = extractMentions(text).filter(isSensitivePath);
1540
+ if (secrets.length) {
1541
+ this.confirm(
1542
+ 'Attach secret-looking files?',
1543
+ `${secrets.map((s) => '@' + s).join(', ')} will be sent to the model in full.`,
1544
+ 'Attach and send',
1545
+ () => this.deliver(text, session, continuation, true),
1546
+ );
1547
+ return;
1548
+ }
1549
+ await this.deliver(text, session, continuation, false);
1550
+ }
1551
+ private async deliver(
1552
+ text: string,
1553
+ session: string,
1554
+ continuation: string | undefined,
1555
+ confirmSensitive: boolean,
1556
+ /** What was typed, when it differs from what is sent (a custom command). */
1557
+ typed = text,
1558
+ ): Promise<void> {
1559
+ await this.client.submit(text, continuation, confirmSensitive);
1560
+ this.editor.addToHistory(typed);
1561
+ this.sent.add(typed);
1562
+ if (this.client.snapshot?.id === session && this.editor.getExpandedText() === typed) {
1043
1563
  this.editor.setText('');
1044
1564
  this.drafts.delete(session);
1045
1565
  }
@@ -1050,16 +1570,143 @@ export class SalaamApp {
1050
1570
  setTab(tab: ViewTab): void {
1051
1571
  this.view.tab = tab;
1052
1572
  this.resetScroll();
1053
- if (tab === 'changes') void this.execute(() => this.loadDiff(this.view.diffOffset));
1573
+ if (tab === 'changes') void this.execute(() => this.loadChanges());
1574
+ this.tui.requestRender();
1575
+ }
1576
+ /** A local coding session reviews its folder against the review base; a coding workspace
1577
+ * keeps its guarded diff. */
1578
+ private loadChanges(): Promise<void> {
1579
+ return interactiveCoding(this.client.snapshot)
1580
+ ? this.loadReview()
1581
+ : this.loadDiff(this.view.diffOffset);
1582
+ }
1583
+ private async loadReview(file = this.view.review?.file ?? 0): Promise<void> {
1584
+ const id = this.client.snapshot?.id;
1585
+ const data = await this.client.changeReview();
1586
+ if (this.client.snapshot?.id !== id) return;
1587
+ const index = data?.files.length ? Math.min(Math.max(0, file), data.files.length - 1) : 0;
1588
+ const path = data?.files[index]?.path;
1589
+ const hunks = data && path ? await this.client.changeHunks(data, path) : undefined;
1590
+ this.view.review = { data, file: index, hunks, hunk: -1 };
1591
+ this.document.invalidate();
1054
1592
  this.tui.requestRender();
1055
1593
  }
1594
+ /** Changes-tab keys: alt+left/right file, alt+up/down hunk; with an empty editor, a accepts,
1595
+ * r reverts and i starts a follow-up instruction about the selection. */
1596
+ private reviewKey(data: string): { consume: true } | undefined {
1597
+ const review = this.view.review;
1598
+ if (!review?.data?.files.length) return;
1599
+ const file = review.data.files[review.file]!;
1600
+ if (
1601
+ matchesKey(data, REVIEW_KEYS.nextFile.key) ||
1602
+ matchesKey(data, REVIEW_KEYS.previousFile.key)
1603
+ ) {
1604
+ const next = review.file + (matchesKey(data, REVIEW_KEYS.nextFile.key) ? 1 : -1);
1605
+ if (next >= 0 && next < review.data.files.length)
1606
+ void this.execute(() => this.loadReview(next));
1607
+ return { consume: true };
1608
+ }
1609
+ if (
1610
+ matchesKey(data, REVIEW_KEYS.nextHunk.key) ||
1611
+ matchesKey(data, REVIEW_KEYS.previousHunk.key)
1612
+ ) {
1613
+ const count = review.hunks?.length ?? 0;
1614
+ review.hunk = Math.max(
1615
+ -1,
1616
+ Math.min(count - 1, review.hunk + (matchesKey(data, REVIEW_KEYS.nextHunk.key) ? 1 : -1)),
1617
+ );
1618
+ this.document.invalidate();
1619
+ this.tui.requestRender();
1620
+ return { consume: true };
1621
+ }
1622
+ if (this.editor.getExpandedText() !== '') return;
1623
+ const hunk = review.hunk >= 0 ? review.hunks?.[review.hunk] : undefined;
1624
+ const what = file.path + (hunk ? ` (hunk ${review.hunk + 1})` : '');
1625
+ const act = (action: 'accept' | 'revert') => async () => {
1626
+ const data = await this.client.changeAct(review.data!, action, file.path, hunk);
1627
+ this.view.review = { data, file: review.file, hunk: -1 };
1628
+ await this.loadReview(review.file);
1629
+ this.client.report((action === 'accept' ? 'Accepted ' : 'Reverted ') + what + '.');
1630
+ };
1631
+ if (data === REVIEW_KEYS.accept.key) {
1632
+ void this.execute(act('accept'));
1633
+ return { consume: true };
1634
+ }
1635
+ if (data === REVIEW_KEYS.revert.key) {
1636
+ this.confirm(
1637
+ 'Revert ' + what,
1638
+ (hunk ? 'Puts this hunk back' : 'Puts this file back') +
1639
+ ' the way it was at the review base. Only the reviewed version is written; if the file changed since, nothing is written and you review again.',
1640
+ 'Revert',
1641
+ act('revert'),
1642
+ );
1643
+ return { consume: true };
1644
+ }
1645
+ if (data === REVIEW_KEYS.instruct.key) {
1646
+ this.setTab('conversation');
1647
+ this.editor.setText(
1648
+ `In ${file.path}${hunk ? ` (the change at ${hunk.text.split('\n')[0]})` : ''}: `,
1649
+ );
1650
+ return { consume: true };
1651
+ }
1652
+ return;
1653
+ }
1654
+ /** /rewind [turn]: choose a turn and what to restore, review, then confirm once. */
1655
+ private async rewind(args: string): Promise<void> {
1656
+ const points = await this.client.checkpoints();
1657
+ if (!points.length)
1658
+ throw new Error(
1659
+ 'No turn has been checkpointed in this session yet. A checkpoint is taken as each turn starts.',
1660
+ );
1661
+ const choose = (attempt: string) =>
1662
+ this.choice(
1663
+ 'Rewind · what to restore',
1664
+ [
1665
+ {
1666
+ value: 'both',
1667
+ label: 'Files and conversation',
1668
+ description: 'Restore files and continue from before this turn in a new session',
1669
+ },
1670
+ {
1671
+ value: 'files',
1672
+ label: 'Files only',
1673
+ description: 'Restore files; keep the conversation',
1674
+ },
1675
+ {
1676
+ value: 'conversation',
1677
+ label: 'Conversation only',
1678
+ description: 'Continue from before this turn in a new session; files stay',
1679
+ },
1680
+ ],
1681
+ (mode) =>
1682
+ void this.execute(async () => {
1683
+ const review = await this.client.rewindReview(attempt, mode as RewindMode);
1684
+ this.confirm(
1685
+ `Rewind to before turn ${review.turn}`,
1686
+ rewindSummary(review),
1687
+ 'Rewind',
1688
+ async () => {
1689
+ const text = await this.client.rewind(review);
1690
+ if (review.mode !== 'files') this.editor.setText(text);
1691
+ },
1692
+ );
1693
+ }),
1694
+ );
1695
+ if (args) {
1696
+ const found = points.find((p) => String(p.turn) === args.trim());
1697
+ if (!found) throw new Error(`No checkpoint for turn ${args.trim()}. /rewind lists them.`);
1698
+ choose(found.attempt_id);
1699
+ return;
1700
+ }
1701
+ this.choice('Rewind to before which turn?', rewindItems(points), choose);
1702
+ }
1056
1703
  private resetScroll(): void {
1057
1704
  if (this.view.tab === 'conversation' || this.view.tab === 'commands') this.scroll.scrollToEnd();
1058
1705
  else this.scroll.scrollTo(0, { disableFollow: true });
1059
1706
  }
1060
1707
  private async refresh(): Promise<void> {
1061
1708
  await this.client.refresh();
1062
- if (this.view.tab === 'changes') await this.loadDiff(this.view.diffOffset);
1709
+ if (this.view.tab === 'changes') await this.loadChanges();
1063
1710
  }
1064
1711
  private async loadDiff(offset: number): Promise<void> {
1065
1712
  if (this.diffBusy) return;
@@ -1099,7 +1746,18 @@ export class SalaamApp {
1099
1746
  void this.execute(async () => {
1100
1747
  const approval = approvals.find((a) => a.id === id)!;
1101
1748
  const reviewed = await this.client.review(approval);
1102
- this.showApproval(reviewed.approval, reviewed.operation);
1749
+ // Desk sessions can approve for the session or always; strict work only ever approves once.
1750
+ const rules = await this.client.approvalRules().catch(() => ({ applies: false }));
1751
+ const onceOnly = rules.applies
1752
+ ? onceOnlyReason(String(reviewed.operation.value))
1753
+ : undefined;
1754
+ this.showApproval(
1755
+ reviewed.approval,
1756
+ reviewed.operation,
1757
+ rules.applies && !onceOnly,
1758
+ onceOnly,
1759
+ rules.applies,
1760
+ );
1103
1761
  });
1104
1762
  if (approvals.length === 1) open(approvals[0]!.id);
1105
1763
  else
@@ -1115,7 +1773,103 @@ export class SalaamApp {
1115
1773
  open,
1116
1774
  );
1117
1775
  }
1118
- private showApproval(approval: Approval, operation: import('./protocol.js').Operation): void {
1776
+ /** /permissions → Approval rules: list them, remove one, or add one in plain words. */
1777
+ private async approvalRules(): Promise<void> {
1778
+ const { applies, rules, problems } = await this.client.approvalRules();
1779
+ if (!applies) {
1780
+ this.client.report('Approval rules apply only to desk coding sessions.');
1781
+ return;
1782
+ }
1783
+ const describe = (r: import('./approval-rules.js').StoredRule) =>
1784
+ `${r.effect} ${r.tool} ${r.command ?? (r.paths ?? []).join(', ')}`.trim();
1785
+ this.choice(
1786
+ 'Approval rules · deny beats ask beats allow · unmatched requests ask',
1787
+ [
1788
+ {
1789
+ value: 'add',
1790
+ label: 'Add a rule',
1791
+ description: 'e.g. allow command npm test * · deny edit src/secrets/**',
1792
+ },
1793
+ ...rules.map((r) => ({
1794
+ value: 'rule:' + r.id,
1795
+ label: describe(r),
1796
+ description:
1797
+ r.source === 'user'
1798
+ ? 'From your permissions.json; edit that file to change it'
1799
+ : r.source === 'repo'
1800
+ ? 'Always, in this repo · choose to remove'
1801
+ : 'This session · choose to remove',
1802
+ })),
1803
+ ...problems.map((p, i) => ({ value: 'problem:' + i, label: 'Problem', description: p })),
1804
+ ],
1805
+ (value) => {
1806
+ if (value === 'add') {
1807
+ this.taskInput(
1808
+ 'New approval rule',
1809
+ '',
1810
+ 'effect tool words-or-paths',
1811
+ 'allow|ask|deny, then command or edit, then command words (a final * takes any further words) or comma-separated path globs. Example: allow command git status',
1812
+ (text) => {
1813
+ const [effect, tool, ...rest] = text.trim().split(/\s+/);
1814
+ const target = rest.join(' ');
1815
+ const rule =
1816
+ tool === 'edit'
1817
+ ? {
1818
+ effect,
1819
+ tool,
1820
+ paths: target
1821
+ .split(',')
1822
+ .map((p) => p.trim())
1823
+ .filter(Boolean),
1824
+ }
1825
+ : { effect, tool, command: target };
1826
+ this.choice(
1827
+ 'Keep this rule for…',
1828
+ [
1829
+ {
1830
+ value: 'session',
1831
+ label: 'This session',
1832
+ description: 'Gone when the session ends',
1833
+ },
1834
+ {
1835
+ value: 'repo',
1836
+ label: 'This repo',
1837
+ description: "Every desk session here; kept in Salaam's state",
1838
+ },
1839
+ ],
1840
+ (scope) =>
1841
+ void this.execute(() =>
1842
+ this.client.addRule(
1843
+ scope as 'session' | 'repo',
1844
+ rule as import('./approval-rules.js').ApprovalRule,
1845
+ ),
1846
+ ),
1847
+ );
1848
+ },
1849
+ );
1850
+ return;
1851
+ }
1852
+ if (!value.startsWith('rule:')) return;
1853
+ const rule = rules.find((r) => 'rule:' + r.id === value)!;
1854
+ if (rule.source === 'user') {
1855
+ this.client.report(
1856
+ 'This rule comes from your permissions.json; edit that file to change it.',
1857
+ );
1858
+ return;
1859
+ }
1860
+ this.confirm('Remove this rule?', describe(rule), 'remove', () =>
1861
+ this.client.removeRule(rule.id),
1862
+ );
1863
+ },
1864
+ );
1865
+ }
1866
+ private showApproval(
1867
+ approval: Approval,
1868
+ operation: import('./protocol.js').Operation,
1869
+ offerRules = false,
1870
+ onceOnly?: string,
1871
+ desk = false,
1872
+ ): void {
1119
1873
  this.showModal(
1120
1874
  new ApprovalModal(
1121
1875
  approval,
@@ -1127,8 +1881,72 @@ export class SalaamApp {
1127
1881
  () => this.closeModal(),
1128
1882
  (decision) => {
1129
1883
  this.closeModal();
1130
- void this.execute(() => this.client.decide(approval, decision));
1884
+ if (decision === 'approve' || decision === 'deny') {
1885
+ void this.execute(() => this.client.decide(approval, decision));
1886
+ return;
1887
+ }
1888
+ if (decision === 'deny-feedback') {
1889
+ // Deny, then tell the agent why in its own turn, the way steering reaches it.
1890
+ this.taskInput(
1891
+ 'Deny and tell it why',
1892
+ '',
1893
+ 'Why not, or what to do instead',
1894
+ 'One line. The request is denied and this reaches the agent as feedback.',
1895
+ (why) =>
1896
+ void this.execute(async () => {
1897
+ await this.client.decide(approval, 'deny');
1898
+ const text = why.trim();
1899
+ if (!text) return;
1900
+ // The turn has usually moved on by now; if it ended, put the reason in the
1901
+ // composer instead of losing it.
1902
+ const feedback = `I denied that request (${safeText(operation.target)}). ${text}`;
1903
+ try {
1904
+ await this.client.steer(feedback);
1905
+ } catch {
1906
+ this.editor.setText(feedback);
1907
+ this.client.report(
1908
+ 'Denied. The turn had ended, so your reason is in the composer; press enter to send it.',
1909
+ );
1910
+ }
1911
+ }),
1912
+ );
1913
+ return;
1914
+ }
1915
+ // The rule is the exact request just reviewed: its command words or its edited paths.
1916
+ // If it cannot become a rule (shell features, odd words), nothing is approved; say why.
1917
+ const approveWith = (scope: 'session' | 'repo') => async () => {
1918
+ // While the approval is pending the status line shows it, not a notice, so a
1919
+ // refused rule is shown where it cannot be missed.
1920
+ try {
1921
+ await this.client.ruleFromOperation(operation.id, scope);
1922
+ } catch (error) {
1923
+ this.showText(
1924
+ 'Not saved as a rule',
1925
+ `${error instanceof Error ? error.message : String(error)}\n\nNothing was approved. Press ctrl+o to approve it once instead.`,
1926
+ );
1927
+ return;
1928
+ }
1929
+ await this.client.decide(approval, 'approve');
1930
+ this.client.report(
1931
+ scope === 'session'
1932
+ ? 'Approved, and the same request will be approved for the rest of this session.'
1933
+ : 'Approved, and the same request will always be approved in this repo. Remove it in /permissions.',
1934
+ );
1935
+ };
1936
+ if (decision === 'approve-session') void this.execute(approveWith('session'));
1937
+ else
1938
+ this.confirm(
1939
+ 'Always allow in this repo?',
1940
+ `${safeText(operation.target)}
1941
+
1942
+ This exact request will be approved without asking in every desk session on this repository, until you remove it in /permissions. It is kept in Salaam's state, not in the repository.`,
1943
+ 'always allow',
1944
+ approveWith('repo'),
1945
+ );
1131
1946
  },
1947
+ offerRules,
1948
+ onceOnly,
1949
+ { desk, reason: approvalReason(String(operation.value), this.view.mode?.mode) },
1132
1950
  ),
1133
1951
  86,
1134
1952
  );
@@ -1181,10 +1999,15 @@ export class SalaamApp {
1181
1999
  },
1182
2000
  ...(current.coding
1183
2001
  ? [
2002
+ {
2003
+ value: 'rules',
2004
+ label: 'Approval rules',
2005
+ description: 'Allow, ask or deny by command words and path globs',
2006
+ },
1184
2007
  {
1185
2008
  value: 'review',
1186
2009
  label: 'Review every mutation',
1187
- description: 'Explicit approval for each edit and unsandboxed shell command',
2010
+ description: 'Explicit approval for each edit and shell command',
1188
2011
  },
1189
2012
  {
1190
2013
  value: 'scoped-edit',
@@ -1195,6 +2018,10 @@ export class SalaamApp {
1195
2018
  : []),
1196
2019
  ],
1197
2020
  (mode) => {
2021
+ if (mode === 'rules') {
2022
+ void this.execute(() => this.approvalRules());
2023
+ return;
2024
+ }
1198
2025
  if (mode === 'inspect-current') {
1199
2026
  this.showText('Permissions and execution boundary', JSON.stringify(current, null, 2));
1200
2027
  return;
@@ -1203,7 +2030,7 @@ export class SalaamApp {
1203
2030
  this.showModal(
1204
2031
  new TextModal(
1205
2032
  'Review permission policy',
1206
- `Session ${session}\nWorkspace ${current.policy.root}\nMode ${mode}\nAuto-edit paths: ${paths.join(', ') || '(none)'}\nAll shell commands still require approval. This is not an OS sandbox. Progression and budgets remain unchanged. Already-running commands are not stopped.`,
2033
+ `Session ${session}\nWorkspace ${current.policy.root}\nMode ${mode}\nAuto-edit paths: ${paths.join(', ') || '(none)'}\nCommands ask unless an approval rule or the session mode allows them (auto allows only sandboxed commands; full access allows all but dangerous ones; YOLO allows everything). Commands run in the host OS sandbox unless full access or YOLO is on. Progression and budgets remain unchanged. Already-running commands are not stopped.`,
1207
2034
  this.view.theme,
1208
2035
  () => this.terminal.rows,
1209
2036
  () => this.tui.requestRender(),
@@ -1832,7 +2659,11 @@ export class SalaamApp {
1832
2659
  label: 'Operator inbox',
1833
2660
  description: 'Shared durable attention and results',
1834
2661
  },
1835
- { value: 'model', label: 'Choose model and provider', description: '/model · new session' },
2662
+ {
2663
+ value: 'model',
2664
+ label: 'Choose model and provider',
2665
+ description: '/model · switches this session',
2666
+ },
1836
2667
  { value: 'help', label: 'Slash commands', description: '/help' },
1837
2668
  { value: 'new', label: 'New session', description: 'ctrl+n' },
1838
2669
  {
@@ -1852,8 +2683,13 @@ export class SalaamApp {
1852
2683
  label: 'Job actions',
1853
2684
  description: 'Bound one-shot admission and durable occurrence recovery',
1854
2685
  },
1855
- { value: 'changes', label: 'Review changes', description: 'ctrl+g · guarded workspace diff' },
2686
+ { value: 'changes', label: 'Review changes', description: 'alt+g · guarded workspace diff' },
1856
2687
  { value: 'commands', label: 'Commands and output', description: 'ctrl+t' },
2688
+ {
2689
+ value: 'transcript',
2690
+ label: 'Full transcript',
2691
+ description: 'ctrl+x · every tool call and its output',
2692
+ },
1857
2693
  {
1858
2694
  value: 'context',
1859
2695
  label: 'Session context',
@@ -1905,11 +2741,11 @@ export class SalaamApp {
1905
2741
  label: 'Cancel current work',
1906
2742
  description: 'Explicitly cancel a running instruction',
1907
2743
  },
1908
- { value: 'refresh', label: 'Refresh connection', description: 'ctrl+r' },
2744
+ { value: 'refresh', label: 'Refresh connection', description: 'f5' },
1909
2745
  {
1910
2746
  value: 'editor',
1911
2747
  label: 'Edit prompt externally',
1912
- description: 'ctrl+e · VISUAL or EDITOR',
2748
+ description: 'ctrl+g · VISUAL or EDITOR',
1913
2749
  },
1914
2750
  {
1915
2751
  value: 'theme',
@@ -2163,46 +2999,18 @@ export class SalaamApp {
2163
2999
  },
2164
3000
  );
2165
3001
  }
3002
+ /** The keys for where you are: `?` on an empty composer, or F1. Built from tui-keymap.ts. */
2166
3003
  private help(): void {
3004
+ const snapshot = this.client.snapshot;
3005
+ const sheet = keySheet({
3006
+ tab: this.view.tab,
3007
+ coding: interactiveCoding(snapshot),
3008
+ running: interactiveCoding(snapshot) && runningAttempt(snapshot) !== undefined,
3009
+ custom: this.customCommands(),
3010
+ });
2167
3011
  this.choice(
2168
- 'Salaam · keyboard',
2169
- [
2170
- {
2171
- value: 'close',
2172
- label: 'Enter · send the exact prompt',
2173
- description: 'The durable outbox saves it before delivery',
2174
- },
2175
- {
2176
- value: 'close',
2177
- label: 'Alt+Enter / Ctrl+J · new line',
2178
- description: 'Shift+Enter also works with supported keyboard protocols',
2179
- },
2180
- {
2181
- value: 'close',
2182
- label: 'Ctrl+K · all actions',
2183
- description: 'Ctrl+P sessions · Ctrl+N new session · Ctrl+O approvals',
2184
- },
2185
- {
2186
- value: 'close',
2187
- label: 'Tab · switch view',
2188
- description: 'Conversation · Changes · Commands · Context',
2189
- },
2190
- {
2191
- value: 'close',
2192
- label: 'PageUp / PageDown · scroll',
2193
- description: 'Mouse wheel also scrolls; Ctrl+Home/End jump to the top/bottom',
2194
- },
2195
- {
2196
- value: 'close',
2197
- label: 'Ctrl+E · external prompt editor',
2198
- description: 'Runs VISUAL or EDITOR with terminal modes restored',
2199
- },
2200
- {
2201
- value: 'close',
2202
- label: 'Ctrl+C · detach',
2203
- description: 'Work continues. Cancel and stop service are separate actions.',
2204
- },
2205
- ],
3012
+ sheet.title,
3013
+ sheet.rows.map((row) => ({ value: 'close', ...row })),
2206
3014
  () => {},
2207
3015
  );
2208
3016
  }
@@ -2244,8 +3052,77 @@ export class SalaamApp {
2244
3052
  }
2245
3053
  }
2246
3054
  }
3055
+ /** Ctrl+C: stop a running coding turn, else clear the composer, else arm; twice within 2 s leaves. */
3056
+ private ctrlC(): void {
3057
+ const attempt = interactiveCoding(this.client.snapshot)
3058
+ ? runningAttempt(this.client.snapshot)
3059
+ : undefined;
3060
+ const action = this.modal
3061
+ ? 'exit'
3062
+ : ctrlCAction({
3063
+ running: attempt !== undefined,
3064
+ text: this.editor.getExpandedText(),
3065
+ armedAt: this.ctrlCArmed,
3066
+ now: Date.now(),
3067
+ });
3068
+ this.ctrlCArmed = undefined;
3069
+ if (action === 'exit') this.finish();
3070
+ else if (action === 'interrupt')
3071
+ void this.execute(async () => {
3072
+ await this.client.cancel(attempt!);
3073
+ this.client.report('Stopped the turn. Press ctrl+c twice to leave.');
3074
+ });
3075
+ else if (action === 'clear') {
3076
+ this.editor.setText('');
3077
+ this.client.report('Cleared. Press ctrl+c again to leave, or ctrl+- to undo.');
3078
+ } else {
3079
+ this.ctrlCArmed = Date.now();
3080
+ this.client.report('Press ctrl+c again to leave. Work continues in the service.');
3081
+ }
3082
+ }
3083
+ private startSearch(): void {
3084
+ this.search = { query: '', index: this.sent.entries.length, draft: this.editor.getText() };
3085
+ this.client.report(searchPrompt('', undefined));
3086
+ }
3087
+ /** Keys while Ctrl+R search is open: type to narrow, ctrl+r for older, enter keeps, esc restores. */
3088
+ private searchKey(data: string): { consume: true } {
3089
+ const s = this.search!,
3090
+ entries = this.sent.entries;
3091
+ const show = () => {
3092
+ const match = s.index < entries.length ? entries[s.index] : undefined;
3093
+ if (match !== undefined) this.editor.setText(match);
3094
+ this.client.report(searchPrompt(s.query, match));
3095
+ };
3096
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+g')) {
3097
+ this.editor.setText(s.draft);
3098
+ this.search = undefined;
3099
+ this.client.report('');
3100
+ } else if (matchesKey(data, 'enter') || matchesKey(data, 'right') || matchesKey(data, 'left')) {
3101
+ this.search = undefined;
3102
+ this.client.report('');
3103
+ } else if (matchesKey(data, 'ctrl+r')) {
3104
+ const found = searchHistory(entries, s.query, s.index);
3105
+ if (found >= 0) s.index = found;
3106
+ show();
3107
+ } else if (matchesKey(data, 'backspace')) {
3108
+ s.query = s.query.slice(0, -1);
3109
+ s.index = searchHistory(entries, s.query, entries.length);
3110
+ if (s.index < 0) s.index = entries.length;
3111
+ if (s.query === '') this.editor.setText(s.draft);
3112
+ show();
3113
+ } else if (data.length === 1 && data >= ' ' && data !== '\x7f') {
3114
+ s.query += data;
3115
+ const found = searchHistory(entries, s.query, Math.min(s.index + 1, entries.length));
3116
+ s.index = found >= 0 ? found : entries.length;
3117
+ if (found < 0) this.editor.setText(s.draft);
3118
+ show();
3119
+ }
3120
+ this.tui.requestRender();
3121
+ return { consume: true };
3122
+ }
2247
3123
  private async initialize(): Promise<void> {
2248
3124
  await this.execute(async () => {
3125
+ if (this.options.start) return this.openHere(this.options.start);
2249
3126
  await this.client.list();
2250
3127
  if (this.options.session)
2251
3128
  await this.client.open(this.options.session, {
@@ -2259,6 +3136,34 @@ export class SalaamApp {
2259
3136
  }
2260
3137
  });
2261
3138
  }
3139
+ /** One command for this folder: start the service if needed, then continue the folder's
3140
+ * latest session, start a new coding session in it, or show the session list. */
3141
+ private async openHere(start: import('./session-pick.js').CodeStart): Promise<void> {
3142
+ const { latestSessionFor, deskUsesAnthropic } = await import('./session-pick.js');
3143
+ await this.startService();
3144
+ if (
3145
+ deskUsesAnthropic(
3146
+ !!this.selectedRoute,
3147
+ this.client.health?.provider_route.provider,
3148
+ existsSync(credentialPath()) || existsSync(credentialPath() + '.json'),
3149
+ )
3150
+ )
3151
+ this.selectedRoute = providerRoute('anthropic');
3152
+ if (start === 'resume') return this.sessions();
3153
+ const cwd = resolve(this.options.cwd ?? process.cwd());
3154
+ const existing = start === 'continue' ? latestSessionFor(this.client.sessions, cwd) : undefined;
3155
+ if (existing)
3156
+ await this.client.open(existing, {
3157
+ observe: this.options.observe,
3158
+ takeover: this.options.takeover,
3159
+ });
3160
+ else {
3161
+ await this.client.create(cwd, false, this.selectedRoute, true);
3162
+ if (start === 'continue')
3163
+ this.client.notice = 'No earlier session in this folder, so this is a new one.';
3164
+ }
3165
+ this.setTab('conversation');
3166
+ }
2262
3167
  async run(): Promise<void> {
2263
3168
  this.running = true;
2264
3169
  const close = () => {
@@ -2278,7 +3183,11 @@ export class SalaamApp {
2278
3183
  if (
2279
3184
  this.client.preview ||
2280
3185
  this.client.snapshot?.approvals.some((a) => a.state === 'pending') ||
2281
- this.client.commands.some((run) => !commandTerminal(run.state))
3186
+ this.client.commands.some((run) => !commandTerminal(run.state)) ||
3187
+ // The progress line counts seconds while a turn runs, so it never shows a stale state.
3188
+ this.client.snapshot?.attempts.some((a) =>
3189
+ ['accepted', 'dispatching', 'running'].includes(a.state),
3190
+ )
2282
3191
  )
2283
3192
  this.tui.requestRender();
2284
3193
  }, 1000);
@@ -2293,6 +3202,7 @@ export class SalaamApp {
2293
3202
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.off(signal, close);
2294
3203
  process.stdin.off('end', close);
2295
3204
  process.stdout.off('error', close);
3205
+ if (this.statusOn) this.herdr.release();
2296
3206
  await this.terminal.drainInput(150, 25);
2297
3207
  process.stdin.pause();
2298
3208
  process.stdin.unref();