@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
@@ -1,8 +1,43 @@
1
1
  import { Markdown, wrapTextWithAnsi, visibleWidth } from '@earendil-works/pi-tui';
2
2
  import { basename } from 'node:path';
3
- import { safeText, shortId, humanTool } from './tui-theme.js';
3
+ import { safeText, shortId } from './tui-theme.js';
4
+ import { typedText } from './shell-passthrough.js';
5
+ import { instructionsLabel, sessionFailure, usageCost, usageInput, } from './tui-client.js';
4
6
  import { workspaceDiff } from './workspace-diff.js';
5
7
  import { commandTerminal } from './coding.js';
8
+ import { interactiveCoding, runningAttempt } from './tui-turn-keys.js';
9
+ import { backgroundNote, changeCounts } from './tui-review.js';
10
+ import { activityVerb, progressLine, toolLine } from './tui-activity.js';
11
+ /** The session's tool calls, oldest first, each as one compact line. */
12
+ export function toolLines(view) {
13
+ const calls = new Map();
14
+ for (const event of view.client.activity) {
15
+ const p = event.payload;
16
+ const key = p.attempt_id + ':' + p.tool_call_id;
17
+ const before = calls.get(key);
18
+ calls.set(key, {
19
+ tool: p.tool,
20
+ input: event.kind === 'tool.started' ? p.input : before?.input,
21
+ finished: event.kind === 'tool.finished' ? { failed: p.failed, output: p.output } : before?.finished,
22
+ parent: p.parent_tool_call_id ? p.attempt_id + ':' + p.parent_tool_call_id : before?.parent,
23
+ });
24
+ }
25
+ return [...calls.entries()].map(([key, c]) => {
26
+ const line = toolLine(c.tool, c.input, c.finished);
27
+ // A command's recorded result is JSON; the transcript shows its output as text.
28
+ return { key, parent: c.parent, output: line.full ?? c.finished?.output, ...line };
29
+ });
30
+ }
31
+ /** How the status line names a mode. */
32
+ export function modeLabel(mode) {
33
+ return ({
34
+ review: 'review',
35
+ 'accept-edits': 'accept edits',
36
+ auto: 'auto',
37
+ inspect: 'inspect only',
38
+ 'scoped-edit': 'scoped edits',
39
+ }[mode] ?? mode);
40
+ }
6
41
  export function modelLabel(client) {
7
42
  const route = client.snapshot?.provider_route ?? client.health?.provider_route;
8
43
  if (!route)
@@ -15,6 +50,14 @@ export function modelLabel(client) {
15
50
  : '';
16
51
  return (route.model.replace('claude-sonnet-', 'Claude Sonnet ').replace(/(\d)-(\d)$/, '$1.$2') + warning);
17
52
  }
53
+ /** 950 → "950", 12_400 → "12.4k", 2_300_000 → "2.3M". */
54
+ export function compactTokens(n) {
55
+ if (n < 1000)
56
+ return String(n);
57
+ if (n < 1_000_000)
58
+ return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '') + 'k';
59
+ return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
60
+ }
18
61
  export class Dynamic {
19
62
  content;
20
63
  constructor(content) {
@@ -65,33 +108,84 @@ export function status(view, width) {
65
108
  const { client: c, theme: t } = view;
66
109
  const pending = c.snapshot?.approvals.filter((a) => a.state === 'pending').length ?? 0;
67
110
  const active = c.snapshot?.attempts.find((a) => ['accepted', 'dispatching', 'running'].includes(a.state));
68
- const state = view.busy
69
- ? 'Working…'
70
- : pending
71
- ? `${pending} approval${pending === 1 ? '' : 's'} waiting · ctrl+o to review`
72
- : c.notice ||
73
- (!c.snapshot
74
- ? 'ctrl+n to start · ctrl+p to return to a session'
75
- : active
76
- ? `Agent ${active.state} · detach anytime`
77
- : c.controlling
78
- ? 'Ready when you are.'
79
- : 'Observing · ctrl+k → Take control');
80
- const tone = pending ? 'amber' : c.connection === 'reconnecting' ? 'amber' : 'muted';
111
+ // The turn is timed from when this terminal first saw it running (attempts carry no start time).
112
+ if (active && view.turn?.attempt !== active.id)
113
+ view.turn = { attempt: active.id, since: Date.now() };
114
+ if (!active)
115
+ view.turn = undefined;
116
+ const turn = active ? view.turn : undefined;
117
+ const running = toolLines(view)
118
+ .filter((l) => l.state === 'running')
119
+ .at(-1);
120
+ const state = turn && !pending
121
+ ? progressLine(activityVerb(running), turn.since, Date.now(), c.usage?.observations
122
+ ? compactTokens(c.usage.known_input + c.usage.known_output)
123
+ : undefined)
124
+ : view.busy
125
+ ? 'Working…'
126
+ : pending
127
+ ? `${pending} approval${pending === 1 ? '' : 's'} waiting · ctrl+o to review`
128
+ : sessionFailure(c.snapshot) ||
129
+ c.notice ||
130
+ (!c.snapshot
131
+ ? 'ctrl+n to start · ctrl+p to return to a session'
132
+ : active
133
+ ? `Agent ${active.state} · detach anytime`
134
+ : c.controlling
135
+ ? 'Ready when you are.'
136
+ : 'Observing · ctrl+k → Take control');
137
+ const tone = pending
138
+ ? 'amber'
139
+ : sessionFailure(c.snapshot) && !view.busy
140
+ ? 'red'
141
+ : turn
142
+ ? 'jade'
143
+ : c.connection === 'reconnecting'
144
+ ? 'amber'
145
+ : 'muted';
146
+ const guidance = c.snapshot?.project_instructions?.files.length
147
+ ? ' Using ' + instructionsLabel(c.snapshot.project_instructions)
148
+ : '';
149
+ const spend = c.usage?.observations
150
+ ? ' ' +
151
+ compactTokens(usageInput(c.usage) + c.usage.known_output) +
152
+ ' tokens · $' +
153
+ usageCost(c.usage).toFixed(2)
154
+ : '';
155
+ const mode = view.mode
156
+ ? ' ' +
157
+ t.ink('mode ' + modeLabel(view.mode.mode), 'muted') +
158
+ (view.mode.access === 'yolo'
159
+ ? ' ' + t.ink('YOLO', 'red')
160
+ : view.mode.access === 'full'
161
+ ? ' ' + t.ink('FULL ACCESS', 'amber')
162
+ : '') +
163
+ t.ink(' · shift+tab', 'muted')
164
+ : '';
81
165
  const identity = c.snapshot
82
166
  ? ' session ' +
83
167
  shortId(c.snapshot.id) +
84
168
  (c.workspace ? ' · workspace ' + shortId(c.workspace.id) + ' r' + c.workspace.revision : '')
85
169
  : '';
86
170
  return [
87
- t.line(t.ink(safeText(state).replace(/\bPi\b/g, 'Worker'), tone) + t.ink(identity, 'muted'), width),
88
- t.line([
89
- t.key('enter', 'send'),
90
- t.key('alt+enter', 'newline'),
91
- t.key('ctrl+k', 'actions'),
92
- t.key('ctrl+p', 'sessions'),
93
- t.key('ctrl+c', 'detach'),
94
- ].join(' '), width),
171
+ t.line(t.ink(safeText(state).replace(/\bPi\b/g, 'Worker'), tone) +
172
+ mode +
173
+ t.ink(safeText(guidance) + spend + identity, 'muted'), width),
174
+ t.line((c.snapshot && interactiveCoding(c.snapshot) && runningAttempt(c.snapshot)
175
+ ? [
176
+ t.key('esc', 'interrupt'),
177
+ t.key('enter', 'steer'),
178
+ t.key('tab', 'queue'),
179
+ t.key('alt+enter', 'newline'),
180
+ t.key('ctrl+c ×2', 'leave'),
181
+ ]
182
+ : [
183
+ t.key('enter', 'send'),
184
+ t.key('alt+enter', 'newline'),
185
+ t.key('ctrl+k', 'actions'),
186
+ t.key('ctrl+p', 'sessions'),
187
+ t.key('ctrl+c ×2', 'leave'),
188
+ ]).join(' '), width),
95
189
  ];
96
190
  }
97
191
  export class DocumentView {
@@ -116,6 +210,56 @@ export class DocumentView {
116
210
  }
117
211
  return cached.markdown.render(Math.max(1, width));
118
212
  }
213
+ /** Per-file review of what changed in the folder since the review base. */
214
+ reviewLines(width) {
215
+ const t = this.view.theme, review = this.view.review, lines = this.title('CHANGES SINCE REVIEW', width);
216
+ if (!review)
217
+ return [...lines, ...this.text('Loading changes…', width)];
218
+ if (!review.data)
219
+ return [
220
+ ...lines,
221
+ ...this.text('Changes appear here after the first turn starts; each turn is checkpointed.', width),
222
+ ];
223
+ const note = backgroundNote(review.data.background_running);
224
+ if (note)
225
+ lines.push(...this.text(note, width));
226
+ if (!review.data.files.length)
227
+ return [
228
+ ...lines,
229
+ ...this.text('Nothing to review: the folder matches what you accepted.', width),
230
+ ];
231
+ review.data.files.forEach((change, i) => {
232
+ const mark = i === review.file ? '▸ ' : ' ';
233
+ lines.push(t.line(mark +
234
+ safeText(change.path) +
235
+ ' ' +
236
+ t.ink(change.status + ' · ' + changeCounts(change), 'muted'), width));
237
+ });
238
+ lines.push(t.fill('', width));
239
+ const file = review.data.files[review.file];
240
+ const hunks = review.hunks ?? [];
241
+ lines.push(...this.title(safeText(file.path) +
242
+ (review.hunk >= 0 ? ` · hunk ${review.hunk + 1} of ${hunks.length}` : ' · whole file'), width));
243
+ if (!hunks.length)
244
+ lines.push(...this.text('No text diff to show for this file.', width));
245
+ hunks.forEach((hunk, i) => {
246
+ const selected = i === review.hunk;
247
+ lines.push(...t
248
+ .highlight(safeText(hunk.text).replace(/\n$/, ''), 'diff')
249
+ .flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width - 4)))
250
+ .map((line) => t.line((selected ? '┃ ' : review.hunk >= 0 ? ' ' : '') + line, width)));
251
+ });
252
+ lines.push(t.fill('', width), t.line(t.key('alt+←/→', 'file') +
253
+ ' ' +
254
+ t.key('alt+↑/↓', 'hunk') +
255
+ ' ' +
256
+ t.key('a', 'accept') +
257
+ ' ' +
258
+ t.key('r', 'revert') +
259
+ ' ' +
260
+ t.key('i', 'instruct'), width), ...this.text('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.', width));
261
+ return lines;
262
+ }
119
263
  title(label, width, role = 'jade') {
120
264
  const t = this.view.theme;
121
265
  return [t.line(t.ink(label, role, true), width)];
@@ -145,7 +289,7 @@ export class DocumentView {
145
289
  : 'A durable workspace that stays with you, in this terminal or over SSH.', width),
146
290
  t.fill('', width),
147
291
  t.line(t.key('ctrl+n', 'new session') + ' ' + t.key('ctrl+p', 'recent sessions'), width),
148
- t.line(t.key('ctrl+k', 'all actions') + ' ' + t.key('ctrl+g', 'review changes'), width),
292
+ t.line(t.key('ctrl+k', 'all actions') + ' ' + t.key('alt+g', 'review changes'), width),
149
293
  ...(c.snapshot
150
294
  ? [
151
295
  t.fill('', width),
@@ -153,13 +297,41 @@ export class DocumentView {
153
297
  ? 'Owned coding workspace · guarded edits'
154
298
  : c.snapshot.capabilities.includes('local-workspace-read')
155
299
  ? c.snapshot.capabilities.includes('local-coding-reviewed')
156
- ? 'Local coding · reviewed edits · commands run as your user'
300
+ ? 'Local coding · reviewed edits · commands ' +
301
+ (this.view.mode?.sandbox
302
+ ? '· ' + this.view.mode.sandbox
303
+ : 'run as your user')
157
304
  : 'Local workspace · read, list and search · /coding for edits and commands'
158
305
  : 'Conversation session · coding tools are off', 'muted'), width),
159
306
  ]
160
307
  : []),
161
308
  ];
162
309
  }
310
+ /** Compact lines for tool calls (with their output in the full transcript), children indented. */
311
+ toolBlock(calls, byKey, width) {
312
+ const t = this.view.theme, lines = [], shown = this.view.transcript ? calls : calls.slice(-8);
313
+ if (shown.length < calls.length)
314
+ lines.push(t.line(t.ink(` … ${calls.length - shown.length} earlier calls · ctrl+x shows all`, 'muted'), width));
315
+ for (const call of shown) {
316
+ let depth = 0, parent = call.parent;
317
+ const visited = new Set();
318
+ while (parent && !visited.has(parent) && depth < 4) {
319
+ visited.add(parent);
320
+ depth++;
321
+ parent = byKey.get(parent)?.parent;
322
+ }
323
+ const indent = depth ? ' '.repeat(depth) + '└─ ' : '';
324
+ lines.push(t.line(t.ink(call.state === 'running' ? '◌ ' : call.state === 'failed' ? '× ' : '✓ ', call.state === 'failed' ? 'red' : call.state === 'running' ? 'amber' : 'jade') + t.ink(indent + call.label), width));
325
+ const detail = this.view.transcript
326
+ ? call.output
327
+ ? safeText(call.output).split('\n').slice(0, 40)
328
+ : []
329
+ : call.tail;
330
+ for (const tail of detail)
331
+ lines.push(...this.text(' ' + ' '.repeat(indent.length) + '│ ' + tail, width));
332
+ }
333
+ return lines;
334
+ }
163
335
  render(width) {
164
336
  const { client: c, theme: t, tab } = this.view;
165
337
  if (!c.snapshot) {
@@ -181,48 +353,43 @@ export class DocumentView {
181
353
  if (c.snapshot.history_truncated)
182
354
  lines.push(...this.text('Recent conversation · earlier history is available through the history command.', width), t.fill('', width));
183
355
  const ids = new Set(c.snapshot.messages.map((m) => m.entry_id));
356
+ // Each turn's tool calls sit where they ran: before the message that followed them.
357
+ const calls = toolLines(this.view), byKey = new Map(calls.map((l) => [l.key, l])), groups = new Map(), rootOf = new Map(), placed = new Set();
358
+ for (const call of calls) {
359
+ let root = call.key;
360
+ const visited = new Set();
361
+ while (byKey.get(root)?.parent && !visited.has(root) && visited.size < 8) {
362
+ visited.add(root);
363
+ root = byKey.get(root).parent;
364
+ }
365
+ groups.set(root, [...(groups.get(root) ?? []), call]);
366
+ rootOf.set(root.slice(root.indexOf(':') + 1), root);
367
+ }
184
368
  for (const message of c.snapshot.messages) {
369
+ const before = (message.tool_calls ?? [])
370
+ .map((id) => rootOf.get(id))
371
+ .filter((root) => !!root && !placed.has(root));
372
+ if (before.length) {
373
+ for (const root of before)
374
+ placed.add(root);
375
+ lines.push(...this.toolBlock(before.flatMap((root) => groups.get(root)), byKey, width), t.fill('', width));
376
+ }
185
377
  lines.push(...this.title(message.role === 'user' ? 'YOU' : 'SALAAM', width, message.role === 'user' ? 'muted' : 'jade'));
186
- lines.push(...this.prose(message.entry_id, message.text, width - 4).map((line) => t.line(line, width)), ...(message.truncated
378
+ lines.push(...this.prose(message.entry_id, message.role === 'user' ? typedText(message.text) : message.text, width - 4).map((line) => t.line(line, width)), ...(message.truncated
187
379
  ? this.text('Message preview truncated. Read its full history entry to inspect all content.', width)
188
380
  : []), t.fill('', width));
189
381
  }
190
382
  for (const submission of [...c.snapshot.submissions]
191
383
  .reverse()
192
384
  .filter((s) => ['accepted', 'dispatching', 'running'].includes(s.state))) {
193
- lines.push(...this.title(submission.state === 'accepted' ? 'YOU · queued' : 'YOU · in progress', width, 'muted'), ...this.text(submission.text, width), t.fill('', width));
385
+ lines.push(...this.title(submission.state === 'accepted' ? 'YOU · queued' : 'YOU · in progress', width, 'muted'), ...this.text(typedText(submission.text), width), t.fill('', width));
194
386
  }
195
- const tools = new Map();
196
- for (const event of c.activity) {
197
- const p = event.payload;
198
- const key = p.attempt_id + ':' + p.tool_call_id;
199
- const before = tools.get(key);
200
- tools.set(key, {
201
- label: event.kind === 'tool.started'
202
- ? humanTool(p.tool, p.input)
203
- : (before?.label ?? humanTool(p.tool)),
204
- state: event.kind === 'tool.started' ? 'running' : p.failed ? 'failed' : 'finished',
205
- failed: !!p.failed,
206
- parent: p.parent_tool_call_id
207
- ? p.attempt_id + ':' + p.parent_tool_call_id
208
- : before?.parent,
209
- });
210
- }
211
- if (tools.size) {
212
- lines.push(...this.title('TOOL ACTIVITY', width, 'muted'));
213
- for (const tool of [...tools.values()].slice(-6)) {
214
- let depth = 0, parent = tool.parent;
215
- const visited = new Set();
216
- while (parent && !visited.has(parent) && depth < 4) {
217
- visited.add(parent);
218
- depth++;
219
- parent = tools.get(parent)?.parent;
220
- }
221
- lines.push(t.line(t.ink(tool.state === 'running' ? '◌ ' : tool.failed ? '× ' : '✓ ', tool.failed ? 'red' : tool.state === 'running' ? 'amber' : 'jade') +
222
- t.ink((depth ? ' '.repeat(depth) + '└─ ' : '') + tool.label) +
223
- t.ink(' ' + tool.state, 'muted'), width));
224
- }
225
- lines.push(t.fill('', width));
387
+ // Calls not placed before a message: the turn still running, or ones history no longer shows.
388
+ const rest = [...groups.entries()].filter(([root]) => !placed.has(root));
389
+ if (rest.length) {
390
+ lines.push(...this.title(this.view.transcript
391
+ ? 'TRANSCRIPT · every tool call'
392
+ : 'TOOLS · ctrl+x for the full transcript', width, 'muted'), ...this.toolBlock(rest.flatMap(([, group]) => group), byKey, width), t.fill('', width));
226
393
  }
227
394
  if (c.preview)
228
395
  lines.push(...this.title('SALAAM · writing', width), ...this.prose('live-preview', c.preview.text, width - 4).map((line) => t.line(line, width)), ...(c.preview.truncated
@@ -239,15 +406,17 @@ export class DocumentView {
239
406
  if (key !== 'live-preview' && !ids.has(key))
240
407
  this.cache.delete(key);
241
408
  }
242
- else if ((tab === 'changes' || tab === 'commands') &&
243
- c.snapshot.capabilities.includes('local-coding-reviewed')) {
244
- const prefix = tab === 'changes' ? 'Local edit:' : 'Local command:';
245
- const operations = c.snapshot.operations.filter((op) => op.kind === 'workspace' && op.target.startsWith(prefix));
246
- lines.push(...this.title(tab === 'changes' ? 'REVIEWED LOCAL EDITS' : 'LOCAL COMMAND RESULTS', width));
409
+ else if (tab === 'changes' && c.snapshot.capabilities.includes('local-coding-reviewed')) {
410
+ lines.push(...this.reviewLines(width));
411
+ }
412
+ else if (tab === 'commands' && c.snapshot.capabilities.includes('local-coding-reviewed')) {
413
+ const operations = c.snapshot.operations.filter((op) => op.kind === 'workspace' && op.target.startsWith('Local command:'));
414
+ lines.push(...this.title('LOCAL COMMAND RESULTS', width));
247
415
  if (!operations.length)
248
- lines.push(...this.text(tab === 'changes'
249
- ? 'Proposed edits appear here with their approval and apply outcome.'
250
- : 'Commands appear here with their recorded exit status and output. Commands run as your local user.', width));
416
+ lines.push(...this.text('Commands appear here with their recorded exit status and output. ' +
417
+ (this.view.mode?.sandbox
418
+ ? 'Where commands run: ' + this.view.mode.sandbox + '.'
419
+ : 'Commands run as your local user.'), width));
251
420
  for (const op of operations.slice().reverse()) {
252
421
  lines.push(t.rule(width), ...this.title(shortId(op.id) + ' · ' + op.state, width, op.state === 'succeeded' ? 'jade' : 'amber'), ...this.text(op.value, width));
253
422
  if (op.receipt)
@@ -258,6 +427,9 @@ export class DocumentView {
258
427
  lines.push(...this.text('Ctrl+O reviews pending approvals. Cancel current work from Ctrl+K stops the active foreground command. These are saved operation records, not a live Git diff.', width));
259
428
  }
260
429
  else if (tab === 'changes') {
430
+ const failed = sessionFailure(c.snapshot);
431
+ if (failed && !c.workspace)
432
+ return this.text(failed, width);
261
433
  if (!c.workspace)
262
434
  return this.text('This session has no coding workspace. Create a coding session to review guarded edits.', width);
263
435
  lines.push(...this.title('WORKSPACE CHANGES', width), ...this.text('Original checkout: ' + c.workspace.source, width), ...this.text('Workspace ' +
@@ -434,7 +606,7 @@ export class DocumentView {
434
606
  : []));
435
607
  lines.push(t.fill('', width), ...this.title('USAGE', width));
436
608
  if (c.usage)
437
- lines.push(...this.text(`${c.usage.observations} provider observations · ${c.usage.unknown_observations} unknown`, width), ...this.text(`Recorded tokens: ${c.usage.known_input} input · ${c.usage.known_output} output`, width), ...this.text(`Recorded cost $${c.usage.known_cost.toFixed(4)} · estimated $${c.usage.estimated_cost.toFixed(4)}`, width));
609
+ lines.push(...this.text(`${c.usage.observations} provider observations · ${c.usage.unknown_observations} unknown`, width), ...this.text(`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`, width), ...this.text(`Recorded cost $${c.usage.known_cost.toFixed(4)} · estimated $${c.usage.estimated_cost.toFixed(4)}`, width));
438
610
  if (c.health?.execution?.backend)
439
611
  lines.push(t.fill('', width), ...this.title('EXECUTION', width), ...this.text(JSON.stringify(c.health.execution.backend, null, 2), width));
440
612
  }
@@ -485,11 +657,13 @@ export function sidebar(view, width) {
485
657
  row(modelLabel(c));
486
658
  if (c.usage) {
487
659
  label('USAGE');
488
- row(c.usage.known_input.toLocaleString() +
660
+ row(usageInput(c.usage).toLocaleString() +
489
661
  ' in · ' +
490
662
  c.usage.known_output.toLocaleString() +
491
663
  ' out');
492
- row('Est. $' + c.usage.estimated_cost.toFixed(4), 'muted');
664
+ if (c.usage.known_cache_read)
665
+ row(c.usage.known_cache_read.toLocaleString() + ' from cache', 'muted');
666
+ row((c.usage.estimated_observations ? 'Est. $' : '$') + usageCost(c.usage).toFixed(4), 'muted');
493
667
  if (c.usage.unknown_observations)
494
668
  row(c.usage.unknown_observations + ' unknown entries', 'amber');
495
669
  }
@@ -502,7 +676,7 @@ export function sidebar(view, width) {
502
676
  : 'Files · local read'
503
677
  : 'Files · off', c.workspace || c.snapshot?.capabilities.includes('local-workspace-read') ? 'jade' : 'muted');
504
678
  row(c.snapshot?.capabilities.includes('local-coding-reviewed')
505
- ? 'Commands · local user'
679
+ ? 'Commands · ' + (view.mode?.sandbox ?? 'local user')
506
680
  : c.workspace && c.health?.execution?.commands
507
681
  ? 'Commands · isolated'
508
682
  : 'Commands · off', c.workspace && c.health?.execution?.commands ? 'jade' : 'muted');