@superjackfruitlabs/salaam 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -0,0 +1,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
+ }
package/src/tui-view.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  import { Markdown, wrapTextWithAnsi, visibleWidth, type Component } from '@earendil-works/pi-tui';
2
2
  import { basename } from 'node:path';
3
3
  import { SalaamTheme, safeText, shortId, humanTool } from './tui-theme.js';
4
- import type { OperatorClient } from './tui-client.js';
4
+ import { typedText } from './shell-passthrough.js';
5
+ import {
6
+ instructionsLabel,
7
+ sessionFailure,
8
+ usageCost,
9
+ usageInput,
10
+ type OperatorClient,
11
+ } from './tui-client.js';
5
12
  import type { WorkspaceChange } from './workspace-diff.js';
6
13
  import { workspaceDiff } from './workspace-diff.js';
7
14
  import { commandTerminal } from './coding.js';
15
+ import { interactiveCoding, runningAttempt } from './tui-turn-keys.js';
16
+ import { backgroundNote, changeCounts } from './tui-review.js';
17
+ import { activityVerb, progressLine, toolLine, type ToolLine } from './tui-activity.js';
8
18
 
9
19
  export type ViewTab = 'conversation' | 'changes' | 'commands' | 'context';
10
20
  export interface DiffPage {
@@ -24,6 +34,57 @@ export interface ViewModel {
24
34
  log?: { id: string; text: string; offset: number; complete: boolean };
25
35
  busy: boolean;
26
36
  continuation?: string;
37
+ /** The Changes tab of a local coding session (tui-review.ts). */
38
+ review?: import('./tui-review.js').ReviewView;
39
+ /** Phase 2b: the session's mode, its access (full access or YOLO) and where commands run. */
40
+ mode?: { mode: string; access?: 'full' | 'yolo'; sandbox?: string };
41
+ /** Trial polish #9: every tool call with its full recorded output, not the compact lines. */
42
+ transcript?: boolean;
43
+ /** When the running turn was first seen, for the progress line's elapsed seconds. */
44
+ turn?: { attempt: string; since: number };
45
+ }
46
+ /** The session's tool calls, oldest first, each as one compact line. */
47
+ export function toolLines(
48
+ view: ViewModel,
49
+ ): (ToolLine & { parent?: string; key: string; output?: string })[] {
50
+ const calls = new Map<
51
+ string,
52
+ {
53
+ tool: string;
54
+ input?: string;
55
+ finished?: { failed?: boolean; output?: string };
56
+ parent?: string;
57
+ }
58
+ >();
59
+ for (const event of view.client.activity) {
60
+ const p = event.payload;
61
+ const key = p.attempt_id + ':' + p.tool_call_id;
62
+ const before = calls.get(key);
63
+ calls.set(key, {
64
+ tool: p.tool,
65
+ input: event.kind === 'tool.started' ? p.input : before?.input,
66
+ finished:
67
+ event.kind === 'tool.finished' ? { failed: p.failed, output: p.output } : before?.finished,
68
+ parent: p.parent_tool_call_id ? p.attempt_id + ':' + p.parent_tool_call_id : before?.parent,
69
+ });
70
+ }
71
+ return [...calls.entries()].map(([key, c]) => {
72
+ const line = toolLine(c.tool, c.input, c.finished);
73
+ // A command's recorded result is JSON; the transcript shows its output as text.
74
+ return { key, parent: c.parent, output: line.full ?? c.finished?.output, ...line };
75
+ });
76
+ }
77
+ /** How the status line names a mode. */
78
+ export function modeLabel(mode: string): string {
79
+ return (
80
+ {
81
+ review: 'review',
82
+ 'accept-edits': 'accept edits',
83
+ auto: 'auto',
84
+ inspect: 'inspect only',
85
+ 'scoped-edit': 'scoped edits',
86
+ }[mode] ?? mode
87
+ );
27
88
  }
28
89
  export function modelLabel(client: OperatorClient): string {
29
90
  const route = client.snapshot?.provider_route ?? client.health?.provider_route;
@@ -38,6 +99,12 @@ export function modelLabel(client: OperatorClient): string {
38
99
  route.model.replace('claude-sonnet-', 'Claude Sonnet ').replace(/(\d)-(\d)$/, '$1.$2') + warning
39
100
  );
40
101
  }
102
+ /** 950 → "950", 12_400 → "12.4k", 2_300_000 → "2.3M". */
103
+ export function compactTokens(n: number): string {
104
+ if (n < 1000) return String(n);
105
+ if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '') + 'k';
106
+ return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
107
+ }
41
108
  export class Dynamic implements Component {
42
109
  constructor(private readonly content: (width: number) => string[]) {}
43
110
  render(width: number): string[] {
@@ -99,19 +166,65 @@ export function status(view: ViewModel, width: number): string[] {
99
166
  const active = c.snapshot?.attempts.find((a) =>
100
167
  ['accepted', 'dispatching', 'running'].includes(a.state),
101
168
  );
102
- const state = view.busy
103
- ? 'Working…'
104
- : pending
105
- ? `${pending} approval${pending === 1 ? '' : 's'} waiting · ctrl+o to review`
106
- : c.notice ||
107
- (!c.snapshot
108
- ? 'ctrl+n to start · ctrl+p to return to a session'
109
- : active
110
- ? `Agent ${active.state} · detach anytime`
111
- : c.controlling
112
- ? 'Ready when you are.'
113
- : 'Observing · ctrl+k → Take control');
114
- const tone = pending ? 'amber' : c.connection === 'reconnecting' ? 'amber' : 'muted';
169
+ // The turn is timed from when this terminal first saw it running (attempts carry no start time).
170
+ if (active && view.turn?.attempt !== active.id)
171
+ view.turn = { attempt: active.id, since: Date.now() };
172
+ if (!active) view.turn = undefined;
173
+ const turn = active ? view.turn : undefined;
174
+ const running = toolLines(view)
175
+ .filter((l) => l.state === 'running')
176
+ .at(-1);
177
+ const state =
178
+ turn && !pending
179
+ ? progressLine(
180
+ activityVerb(running),
181
+ turn.since,
182
+ Date.now(),
183
+ c.usage?.observations
184
+ ? compactTokens(c.usage.known_input + c.usage.known_output)
185
+ : undefined,
186
+ )
187
+ : view.busy
188
+ ? 'Working…'
189
+ : pending
190
+ ? `${pending} approval${pending === 1 ? '' : 's'} waiting · ctrl+o to review`
191
+ : sessionFailure(c.snapshot) ||
192
+ c.notice ||
193
+ (!c.snapshot
194
+ ? 'ctrl+n to start · ctrl+p to return to a session'
195
+ : active
196
+ ? `Agent ${active.state} · detach anytime`
197
+ : c.controlling
198
+ ? 'Ready when you are.'
199
+ : 'Observing · ctrl+k → Take control');
200
+ const tone = pending
201
+ ? 'amber'
202
+ : sessionFailure(c.snapshot) && !view.busy
203
+ ? 'red'
204
+ : turn
205
+ ? 'jade'
206
+ : c.connection === 'reconnecting'
207
+ ? 'amber'
208
+ : 'muted';
209
+ const guidance = c.snapshot?.project_instructions?.files.length
210
+ ? ' Using ' + instructionsLabel(c.snapshot.project_instructions)
211
+ : '';
212
+ const spend = c.usage?.observations
213
+ ? ' ' +
214
+ compactTokens(usageInput(c.usage) + c.usage.known_output) +
215
+ ' tokens · $' +
216
+ usageCost(c.usage).toFixed(2)
217
+ : '';
218
+ const mode = view.mode
219
+ ? ' ' +
220
+ t.ink('mode ' + modeLabel(view.mode.mode), 'muted') +
221
+ (view.mode.access === 'yolo'
222
+ ? ' ' + t.ink('YOLO', 'red')
223
+ : view.mode.access === 'full'
224
+ ? ' ' + t.ink('FULL ACCESS', 'amber')
225
+ : '') +
226
+ t.ink(' · shift+tab', 'muted')
227
+ : '';
115
228
  const identity = c.snapshot
116
229
  ? ' session ' +
117
230
  shortId(c.snapshot.id) +
@@ -119,17 +232,28 @@ export function status(view: ViewModel, width: number): string[] {
119
232
  : '';
120
233
  return [
121
234
  t.line(
122
- t.ink(safeText(state).replace(/\bPi\b/g, 'Worker'), tone) + t.ink(identity, 'muted'),
235
+ t.ink(safeText(state).replace(/\bPi\b/g, 'Worker'), tone) +
236
+ mode +
237
+ t.ink(safeText(guidance) + spend + identity, 'muted'),
123
238
  width,
124
239
  ),
125
240
  t.line(
126
- [
127
- t.key('enter', 'send'),
128
- t.key('alt+enter', 'newline'),
129
- t.key('ctrl+k', 'actions'),
130
- t.key('ctrl+p', 'sessions'),
131
- t.key('ctrl+c', 'detach'),
132
- ].join(' '),
241
+ (c.snapshot && interactiveCoding(c.snapshot) && runningAttempt(c.snapshot)
242
+ ? [
243
+ t.key('esc', 'interrupt'),
244
+ t.key('enter', 'steer'),
245
+ t.key('tab', 'queue'),
246
+ t.key('alt+enter', 'newline'),
247
+ t.key('ctrl+c ×2', 'leave'),
248
+ ]
249
+ : [
250
+ t.key('enter', 'send'),
251
+ t.key('alt+enter', 'newline'),
252
+ t.key('ctrl+k', 'actions'),
253
+ t.key('ctrl+p', 'sessions'),
254
+ t.key('ctrl+c ×2', 'leave'),
255
+ ]
256
+ ).join(' '),
133
257
  width,
134
258
  ),
135
259
  ];
@@ -154,6 +278,80 @@ export class DocumentView implements Component {
154
278
  }
155
279
  return cached.markdown.render(Math.max(1, width));
156
280
  }
281
+ /** Per-file review of what changed in the folder since the review base. */
282
+ private reviewLines(width: number): string[] {
283
+ const t = this.view.theme,
284
+ review = this.view.review,
285
+ lines = this.title('CHANGES SINCE REVIEW', width);
286
+ if (!review) return [...lines, ...this.text('Loading changes…', width)];
287
+ if (!review.data)
288
+ return [
289
+ ...lines,
290
+ ...this.text(
291
+ 'Changes appear here after the first turn starts; each turn is checkpointed.',
292
+ width,
293
+ ),
294
+ ];
295
+ const note = backgroundNote(review.data.background_running);
296
+ if (note) lines.push(...this.text(note, width));
297
+ if (!review.data.files.length)
298
+ return [
299
+ ...lines,
300
+ ...this.text('Nothing to review: the folder matches what you accepted.', width),
301
+ ];
302
+ review.data.files.forEach((change, i) => {
303
+ const mark = i === review.file ? '▸ ' : ' ';
304
+ lines.push(
305
+ t.line(
306
+ mark +
307
+ safeText(change.path) +
308
+ ' ' +
309
+ t.ink(change.status + ' · ' + changeCounts(change), 'muted'),
310
+ width,
311
+ ),
312
+ );
313
+ });
314
+ lines.push(t.fill('', width));
315
+ const file = review.data.files[review.file]!;
316
+ const hunks = review.hunks ?? [];
317
+ lines.push(
318
+ ...this.title(
319
+ safeText(file.path) +
320
+ (review.hunk >= 0 ? ` · hunk ${review.hunk + 1} of ${hunks.length}` : ' · whole file'),
321
+ width,
322
+ ),
323
+ );
324
+ if (!hunks.length) lines.push(...this.text('No text diff to show for this file.', width));
325
+ hunks.forEach((hunk, i) => {
326
+ const selected = i === review.hunk;
327
+ lines.push(
328
+ ...t
329
+ .highlight(safeText(hunk.text).replace(/\n$/, ''), 'diff')
330
+ .flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width - 4)))
331
+ .map((line) => t.line((selected ? '┃ ' : review.hunk >= 0 ? ' ' : '') + line, width)),
332
+ );
333
+ });
334
+ lines.push(
335
+ t.fill('', width),
336
+ t.line(
337
+ t.key('alt+←/→', 'file') +
338
+ ' ' +
339
+ t.key('alt+↑/↓', 'hunk') +
340
+ ' ' +
341
+ t.key('a', 'accept') +
342
+ ' ' +
343
+ t.key('r', 'revert') +
344
+ ' ' +
345
+ t.key('i', 'instruct'),
346
+ width,
347
+ ),
348
+ ...this.text(
349
+ 'a/r/i work while the message box is empty. Accept marks a change reviewed; revert writes the reviewed base back. /rewind returns to before any turn.',
350
+ width,
351
+ ),
352
+ );
353
+ return lines;
354
+ }
157
355
  private title(label: string, width: number, role: 'jade' | 'amber' | 'muted' = 'jade'): string[] {
158
356
  const t = this.view.theme;
159
357
  return [t.line(t.ink(label, role, true), width)];
@@ -191,7 +389,7 @@ export class DocumentView implements Component {
191
389
  ),
192
390
  t.fill('', width),
193
391
  t.line(t.key('ctrl+n', 'new session') + ' ' + t.key('ctrl+p', 'recent sessions'), width),
194
- t.line(t.key('ctrl+k', 'all actions') + ' ' + t.key('ctrl+g', 'review changes'), width),
392
+ t.line(t.key('ctrl+k', 'all actions') + ' ' + t.key('alt+g', 'review changes'), width),
195
393
  ...(c.snapshot
196
394
  ? [
197
395
  t.fill('', width),
@@ -201,7 +399,10 @@ export class DocumentView implements Component {
201
399
  ? 'Owned coding workspace · guarded edits'
202
400
  : c.snapshot.capabilities.includes('local-workspace-read')
203
401
  ? c.snapshot.capabilities.includes('local-coding-reviewed')
204
- ? 'Local coding · reviewed edits · commands run as your user'
402
+ ? 'Local coding · reviewed edits · commands ' +
403
+ (this.view.mode?.sandbox
404
+ ? '· ' + this.view.mode.sandbox
405
+ : 'run as your user')
205
406
  : 'Local workspace · read, list and search · /coding for edits and commands'
206
407
  : 'Conversation session · coding tools are off',
207
408
  'muted',
@@ -212,6 +413,51 @@ export class DocumentView implements Component {
212
413
  : []),
213
414
  ];
214
415
  }
416
+ /** Compact lines for tool calls (with their output in the full transcript), children indented. */
417
+ private toolBlock(
418
+ calls: ReturnType<typeof toolLines>,
419
+ byKey: Map<string, ReturnType<typeof toolLines>[number]>,
420
+ width: number,
421
+ ): string[] {
422
+ const t = this.view.theme,
423
+ lines: string[] = [],
424
+ shown = this.view.transcript ? calls : calls.slice(-8);
425
+ if (shown.length < calls.length)
426
+ lines.push(
427
+ t.line(
428
+ t.ink(` … ${calls.length - shown.length} earlier calls · ctrl+x shows all`, 'muted'),
429
+ width,
430
+ ),
431
+ );
432
+ for (const call of shown) {
433
+ let depth = 0,
434
+ parent = call.parent;
435
+ const visited = new Set<string>();
436
+ while (parent && !visited.has(parent) && depth < 4) {
437
+ visited.add(parent);
438
+ depth++;
439
+ parent = byKey.get(parent)?.parent;
440
+ }
441
+ const indent = depth ? ' '.repeat(depth) + '└─ ' : '';
442
+ lines.push(
443
+ t.line(
444
+ t.ink(
445
+ call.state === 'running' ? '◌ ' : call.state === 'failed' ? '× ' : '✓ ',
446
+ call.state === 'failed' ? 'red' : call.state === 'running' ? 'amber' : 'jade',
447
+ ) + t.ink(indent + call.label),
448
+ width,
449
+ ),
450
+ );
451
+ const detail = this.view.transcript
452
+ ? call.output
453
+ ? safeText(call.output).split('\n').slice(0, 40)
454
+ : []
455
+ : call.tail;
456
+ for (const tail of detail)
457
+ lines.push(...this.text(' ' + ' '.repeat(indent.length) + '│ ' + tail, width));
458
+ }
459
+ return lines;
460
+ }
215
461
  render(width: number): string[] {
216
462
  const { client: c, theme: t, tab } = this.view;
217
463
  if (!c.snapshot) {
@@ -245,7 +491,37 @@ export class DocumentView implements Component {
245
491
  t.fill('', width),
246
492
  );
247
493
  const ids = new Set(c.snapshot.messages.map((m) => m.entry_id));
494
+ // Each turn's tool calls sit where they ran: before the message that followed them.
495
+ const calls = toolLines(this.view),
496
+ byKey = new Map(calls.map((l) => [l.key, l])),
497
+ groups = new Map<string, typeof calls>(),
498
+ rootOf = new Map<string, string>(),
499
+ placed = new Set<string>();
500
+ for (const call of calls) {
501
+ let root = call.key;
502
+ const visited = new Set<string>();
503
+ while (byKey.get(root)?.parent && !visited.has(root) && visited.size < 8) {
504
+ visited.add(root);
505
+ root = byKey.get(root)!.parent!;
506
+ }
507
+ groups.set(root, [...(groups.get(root) ?? []), call]);
508
+ rootOf.set(root.slice(root.indexOf(':') + 1), root);
509
+ }
248
510
  for (const message of c.snapshot.messages) {
511
+ const before = (message.tool_calls ?? [])
512
+ .map((id) => rootOf.get(id))
513
+ .filter((root): root is string => !!root && !placed.has(root));
514
+ if (before.length) {
515
+ for (const root of before) placed.add(root);
516
+ lines.push(
517
+ ...this.toolBlock(
518
+ before.flatMap((root) => groups.get(root)!),
519
+ byKey,
520
+ width,
521
+ ),
522
+ t.fill('', width),
523
+ );
524
+ }
249
525
  lines.push(
250
526
  ...this.title(
251
527
  message.role === 'user' ? 'YOU' : 'SALAAM',
@@ -254,9 +530,11 @@ export class DocumentView implements Component {
254
530
  ),
255
531
  );
256
532
  lines.push(
257
- ...this.prose(message.entry_id, message.text, width - 4).map((line) =>
258
- t.line(line, width),
259
- ),
533
+ ...this.prose(
534
+ message.entry_id,
535
+ message.role === 'user' ? typedText(message.text) : message.text,
536
+ width - 4,
537
+ ).map((line) => t.line(line, width)),
260
538
  ...(message.truncated
261
539
  ? this.text(
262
540
  'Message preview truncated. Read its full history entry to inspect all content.',
@@ -275,54 +553,28 @@ export class DocumentView implements Component {
275
553
  width,
276
554
  'muted',
277
555
  ),
278
- ...this.text(submission.text, width),
556
+ ...this.text(typedText(submission.text), width),
279
557
  t.fill('', width),
280
558
  );
281
559
  }
282
- const tools = new Map<
283
- string,
284
- { label: string; state: string; failed: boolean; parent?: string }
285
- >();
286
- for (const event of c.activity) {
287
- const p = event.payload;
288
- const key = p.attempt_id + ':' + p.tool_call_id;
289
- const before = tools.get(key);
290
- tools.set(key, {
291
- label:
292
- event.kind === 'tool.started'
293
- ? humanTool(p.tool, p.input)
294
- : (before?.label ?? humanTool(p.tool)),
295
- state: event.kind === 'tool.started' ? 'running' : p.failed ? 'failed' : 'finished',
296
- failed: !!p.failed,
297
- parent: p.parent_tool_call_id
298
- ? p.attempt_id + ':' + p.parent_tool_call_id
299
- : before?.parent,
300
- });
301
- }
302
- if (tools.size) {
303
- lines.push(...this.title('TOOL ACTIVITY', width, 'muted'));
304
- for (const tool of [...tools.values()].slice(-6)) {
305
- let depth = 0,
306
- parent = tool.parent;
307
- const visited = new Set<string>();
308
- while (parent && !visited.has(parent) && depth < 4) {
309
- visited.add(parent);
310
- depth++;
311
- parent = tools.get(parent)?.parent;
312
- }
313
- lines.push(
314
- t.line(
315
- t.ink(
316
- tool.state === 'running' ? '◌ ' : tool.failed ? '× ' : '✓ ',
317
- tool.failed ? 'red' : tool.state === 'running' ? 'amber' : 'jade',
318
- ) +
319
- t.ink((depth ? ' '.repeat(depth) + '└─ ' : '') + tool.label) +
320
- t.ink(' ' + tool.state, 'muted'),
321
- width,
322
- ),
323
- );
324
- }
325
- lines.push(t.fill('', width));
560
+ // Calls not placed before a message: the turn still running, or ones history no longer shows.
561
+ const rest = [...groups.entries()].filter(([root]) => !placed.has(root));
562
+ if (rest.length) {
563
+ lines.push(
564
+ ...this.title(
565
+ this.view.transcript
566
+ ? 'TRANSCRIPT · every tool call'
567
+ : 'TOOLS · ctrl+x for the full transcript',
568
+ width,
569
+ 'muted',
570
+ ),
571
+ ...this.toolBlock(
572
+ rest.flatMap(([, group]) => group),
573
+ byKey,
574
+ width,
575
+ ),
576
+ t.fill('', width),
577
+ );
326
578
  }
327
579
  if (c.preview)
328
580
  lines.push(
@@ -359,23 +611,20 @@ export class DocumentView implements Component {
359
611
  );
360
612
  for (const key of this.cache.keys())
361
613
  if (key !== 'live-preview' && !ids.has(key)) this.cache.delete(key);
362
- } else if (
363
- (tab === 'changes' || tab === 'commands') &&
364
- c.snapshot.capabilities.includes('local-coding-reviewed')
365
- ) {
366
- const prefix = tab === 'changes' ? 'Local edit:' : 'Local command:';
614
+ } else if (tab === 'changes' && c.snapshot.capabilities.includes('local-coding-reviewed')) {
615
+ lines.push(...this.reviewLines(width));
616
+ } else if (tab === 'commands' && c.snapshot.capabilities.includes('local-coding-reviewed')) {
367
617
  const operations = c.snapshot.operations.filter(
368
- (op) => op.kind === 'workspace' && op.target.startsWith(prefix),
369
- );
370
- lines.push(
371
- ...this.title(tab === 'changes' ? 'REVIEWED LOCAL EDITS' : 'LOCAL COMMAND RESULTS', width),
618
+ (op) => op.kind === 'workspace' && op.target.startsWith('Local command:'),
372
619
  );
620
+ lines.push(...this.title('LOCAL COMMAND RESULTS', width));
373
621
  if (!operations.length)
374
622
  lines.push(
375
623
  ...this.text(
376
- tab === 'changes'
377
- ? 'Proposed edits appear here with their approval and apply outcome.'
378
- : 'Commands appear here with their recorded exit status and output. Commands run as your local user.',
624
+ 'Commands appear here with their recorded exit status and output. ' +
625
+ (this.view.mode?.sandbox
626
+ ? 'Where commands run: ' + this.view.mode.sandbox + '.'
627
+ : 'Commands run as your local user.'),
379
628
  width,
380
629
  ),
381
630
  );
@@ -400,6 +649,8 @@ export class DocumentView implements Component {
400
649
  ),
401
650
  );
402
651
  } else if (tab === 'changes') {
652
+ const failed = sessionFailure(c.snapshot);
653
+ if (failed && !c.workspace) return this.text(failed, width);
403
654
  if (!c.workspace)
404
655
  return this.text(
405
656
  'This session has no coding workspace. Create a coding session to review guarded edits.',
@@ -810,7 +1061,7 @@ export class DocumentView implements Component {
810
1061
  width,
811
1062
  ),
812
1063
  ...this.text(
813
- `Recorded tokens: ${c.usage.known_input} input · ${c.usage.known_output} output`,
1064
+ `Recorded tokens: ${usageInput(c.usage)} input (${c.usage.known_cache_read ?? 0} cache reads, ${c.usage.known_cache_write ?? 0} cache writes) · ${c.usage.known_output} output`,
814
1065
  width,
815
1066
  ),
816
1067
  ...this.text(
@@ -881,12 +1132,14 @@ export function sidebar(view: ViewModel, width: number): string[] {
881
1132
  if (c.usage) {
882
1133
  label('USAGE');
883
1134
  row(
884
- c.usage.known_input.toLocaleString() +
1135
+ usageInput(c.usage).toLocaleString() +
885
1136
  ' in · ' +
886
1137
  c.usage.known_output.toLocaleString() +
887
1138
  ' out',
888
1139
  );
889
- row('Est. $' + c.usage.estimated_cost.toFixed(4), 'muted');
1140
+ if (c.usage.known_cache_read)
1141
+ row(c.usage.known_cache_read.toLocaleString() + ' from cache', 'muted');
1142
+ row((c.usage.estimated_observations ? 'Est. $' : '$') + usageCost(c.usage).toFixed(4), 'muted');
890
1143
  if (c.usage.unknown_observations)
891
1144
  row(c.usage.unknown_observations + ' unknown entries', 'amber');
892
1145
  }
@@ -903,7 +1156,7 @@ export function sidebar(view: ViewModel, width: number): string[] {
903
1156
  );
904
1157
  row(
905
1158
  c.snapshot?.capabilities.includes('local-coding-reviewed')
906
- ? 'Commands · local user'
1159
+ ? 'Commands · ' + (view.mode?.sandbox ?? 'local user')
907
1160
  : c.workspace && c.health?.execution?.commands
908
1161
  ? 'Commands · isolated'
909
1162
  : 'Commands · off',