@1agh/maude 0.45.1 → 0.46.0

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 (91) hide show
  1. package/apps/studio/acp/bridge.ts +628 -74
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/annotations-snap.ts +50 -10
  6. package/apps/studio/api.ts +344 -14
  7. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  8. package/apps/studio/bin/_import-asset.mjs +35 -5
  9. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  10. package/apps/studio/bin/_png-playwright.mjs +38 -4
  11. package/apps/studio/bin/_pw-launch.mjs +52 -0
  12. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  13. package/apps/studio/bin/_smart-frames.mjs +419 -0
  14. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  15. package/apps/studio/bin/smart-frames.sh +30 -0
  16. package/apps/studio/canvas-edit.ts +302 -3
  17. package/apps/studio/canvas-lib.tsx +203 -11
  18. package/apps/studio/canvas-shell.tsx +167 -28
  19. package/apps/studio/client/app.jsx +678 -176
  20. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  21. package/apps/studio/client/panels/ChatPanel.jsx +846 -133
  22. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  23. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  24. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  25. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  26. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  27. package/apps/studio/client/panels/acp-capabilities.js +82 -0
  28. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  29. package/apps/studio/client/panels/acp-runtime.js +248 -9
  30. package/apps/studio/client/panels/acp-usage.js +65 -0
  31. package/apps/studio/client/panels/transcript-view.js +36 -0
  32. package/apps/studio/client/styles/6-acp-chat.css +689 -11
  33. package/apps/studio/dist/client.bundle.js +1738 -1736
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/generation/whisper-models.test.ts +28 -1
  44. package/apps/studio/generation/whisper-models.ts +22 -7
  45. package/apps/studio/http.ts +246 -9
  46. package/apps/studio/print/marks.ts +113 -0
  47. package/apps/studio/print/units.ts +269 -0
  48. package/apps/studio/print-overlay-content.tsx +132 -0
  49. package/apps/studio/test/acp-bridge.test.ts +11 -6
  50. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  51. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  52. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  53. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  54. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  56. package/apps/studio/test/acp-permission.test.ts +262 -0
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  58. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  59. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  60. package/apps/studio/test/acp-transcript.test.ts +75 -2
  61. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  62. package/apps/studio/test/acp-usage.test.ts +143 -0
  63. package/apps/studio/test/annotations-snap.test.ts +56 -0
  64. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  65. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  66. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  67. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  68. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  69. package/apps/studio/test/exporters/png.test.ts +49 -1
  70. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  73. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  74. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  75. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  76. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  77. package/apps/studio/test/import-asset.test.ts +31 -3
  78. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  79. package/apps/studio/test/print-marks.test.ts +113 -0
  80. package/apps/studio/test/print-units.test.ts +173 -0
  81. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  82. package/apps/studio/use-chrome-visibility.tsx +19 -0
  83. package/apps/studio/use-element-resize.tsx +21 -2
  84. package/apps/studio/use-snap-guides.tsx +73 -5
  85. package/apps/studio/use-spacing-handles.tsx +9 -5
  86. package/apps/studio/whats-new.json +62 -0
  87. package/cli/commands/design.mjs +116 -3
  88. package/cli/lib/pkg-root.mjs +42 -10
  89. package/cli/lib/pkg-root.test.mjs +33 -1
  90. package/package.json +11 -9
  91. package/plugins/design/dependencies.json +35 -0
@@ -144,6 +144,45 @@ export function createAcpConnection() {
144
144
  const commandListeners = new Set();
145
145
  let commands = [];
146
146
 
147
+ // Session capabilities (feature-acp-panel-dynamic-claude-code-capabilities)
148
+ // — the live, dynamic replacement for the old hardcoded MODELS/EFFORTS
149
+ // arrays. `caps` = `{modes, configOptions}`, sourced entirely from the ACP
150
+ // session (never a static list); `sessionInfo` = the agent-generated title.
151
+ const capsListeners = new Set();
152
+ let caps = { modes: null, configOptions: [] };
153
+ const sessionInfoListeners = new Set();
154
+ let sessionInfo = { title: null, updatedAt: null };
155
+
156
+ // Usage (Milestone D) — the raw `usage` frame's payload, replayed on
157
+ // subscribe like caps/commands/sessionInfo above. Parsed into render-ready
158
+ // shape by acp-usage.js's parseUsage, not here (keep this file transport-only).
159
+ const usageListeners = new Set();
160
+ let usage = null;
161
+
162
+ // Pending permission requests (Milestone B — retires DDR-125 F2's blanket
163
+ // auto-approve). Surfaced OUTSIDE the assistant-ui message stream (like
164
+ // commands/caps above) via a panel-level subscription, since a request can
165
+ // arrive mid-turn and the run() loop only understands text/tool-call/
166
+ // reasoning parts — not "pause here for a human decision."
167
+ const permissionListeners = new Set();
168
+ let pendingPermissions = [];
169
+ function emitPermissions() {
170
+ const snap = pendingPermissions.slice();
171
+ for (const fn of permissionListeners) fn(snap);
172
+ }
173
+
174
+ // Pending elicitation requests (feature-acp-ask-user-question — AskUserQuestion
175
+ // + generic MCP form input). Same "outside the assistant-ui message stream"
176
+ // treatment as pendingPermissions above, for the same reason: a request can
177
+ // arrive mid-turn and the run() loop only understands text/tool-call/reasoning
178
+ // parts, not "pause here for a human form."
179
+ const elicitationListeners = new Set();
180
+ let pendingElicitations = [];
181
+ function emitElicitations() {
182
+ const snap = pendingElicitations.slice();
183
+ for (const fn of elicitationListeners) fn(snap);
184
+ }
185
+
147
186
  function emitStatus() {
148
187
  for (const fn of statusListeners) fn({ ...status });
149
188
  }
@@ -184,6 +223,22 @@ export function createAcpConnection() {
184
223
  emitBackground();
185
224
  }
186
225
 
226
+ // Connection-problem error card (Task C3) — the bridge `error` frame (a
227
+ // socket/adapter failure mid-turn) is ALSO thrown from prompt()'s generator
228
+ // (so assistant-ui's own turn-failed bookkeeping still runs), but a thrown
229
+ // generator error renders as a bare, unstyled assistant-ui fallback. Expose
230
+ // it as its own channel too so the panel can render a proper retry card
231
+ // instead — same "chrome, not turn content" treatment as commands/caps.
232
+ const errorListeners = new Set();
233
+ let lastError = null; // { message } | null
234
+ function emitError() {
235
+ for (const fn of errorListeners) fn(lastError);
236
+ }
237
+ function setError(message) {
238
+ lastError = message ? { message } : null;
239
+ emitError();
240
+ }
241
+
187
242
  function onFrame(frame) {
188
243
  if (frame.t === 'ready') {
189
244
  status.ready = true;
@@ -199,6 +254,62 @@ export function createAcpConnection() {
199
254
  for (const fn of commandListeners) fn(commands);
200
255
  return;
201
256
  }
257
+ // Same treatment for the capability channel — chrome, not turn content,
258
+ // arrives on establish + on every live mode/config-option change.
259
+ if (frame.t === 'caps') {
260
+ caps = { modes: frame.modes ?? null, configOptions: frame.configOptions ?? [] };
261
+ for (const fn of capsListeners) fn(caps);
262
+ return;
263
+ }
264
+ if (frame.t === 'session-info') {
265
+ sessionInfo = { title: frame.title ?? null, updatedAt: frame.updatedAt ?? null };
266
+ for (const fn of sessionInfoListeners) fn(sessionInfo);
267
+ return;
268
+ }
269
+ if (frame.t === 'usage') {
270
+ usage = frame.usage ?? null;
271
+ for (const fn of usageListeners) fn(usage);
272
+ return;
273
+ }
274
+ if (frame.t === 'permission-request') {
275
+ pendingPermissions = [
276
+ ...pendingPermissions,
277
+ { id: frame.id, toolCall: frame.toolCall, options: frame.options ?? [] },
278
+ ];
279
+ emitPermissions();
280
+ return;
281
+ }
282
+ if (frame.t === 'elicitation-request') {
283
+ pendingElicitations = [
284
+ ...pendingElicitations,
285
+ {
286
+ id: frame.id,
287
+ message: frame.message,
288
+ mode: frame.mode,
289
+ requestedSchema: frame.requestedSchema,
290
+ // Present only when this elicitation is scoped to a specific tool
291
+ // call (e.g. the built-in AskUserQuestion) — absent for a raw
292
+ // session-scoped MCP-server elicitation. ElicitationPrompt.jsx uses
293
+ // this to avoid a blanket "from Claude" attribution it can't back up.
294
+ toolCallId: frame.toolCallId,
295
+ },
296
+ ];
297
+ emitElicitations();
298
+ return;
299
+ }
300
+ // The bridge settled a pending elicitation itself (timeout/cancel/stop) —
301
+ // drop it from the pending list even though the client never responded,
302
+ // so the card doesn't sit open with a Submit button that's already dead.
303
+ // A client-driven response already removed its own entry optimistically
304
+ // (respondElicitation below), so this is a no-op for that path — it only
305
+ // does real work for a settlement the client didn't initiate.
306
+ if (frame.t === 'elicitation-resolved') {
307
+ if (pendingElicitations.some((p) => p.id === frame.id)) {
308
+ pendingElicitations = pendingElicitations.filter((p) => p.id !== frame.id);
309
+ emitElicitations();
310
+ }
311
+ return;
312
+ }
202
313
  // Everything else belongs to the active prompt turn.
203
314
  trackActivity(frame);
204
315
  if (turnHandler) turnHandler(frame);
@@ -256,6 +367,13 @@ export function createAcpConnection() {
256
367
  return () => backgroundListeners.delete(fn);
257
368
  },
258
369
 
370
+ /** Subscribe to the connection-problem error card's source (Task C3); replays the current one, if any. */
371
+ onError(fn) {
372
+ errorListeners.add(fn);
373
+ fn(lastError);
374
+ return () => errorListeners.delete(fn);
375
+ },
376
+
259
377
  /** Subscribe to the slash-command catalogue; replays the current list. */
260
378
  onCommands(fn) {
261
379
  commandListeners.add(fn);
@@ -263,12 +381,111 @@ export function createAcpConnection() {
263
381
  return () => commandListeners.delete(fn);
264
382
  },
265
383
 
384
+ /** Subscribe to the live capability set (`{modes, configOptions}`); replays the current snapshot. */
385
+ onCaps(fn) {
386
+ capsListeners.add(fn);
387
+ fn(caps);
388
+ return () => capsListeners.delete(fn);
389
+ },
390
+
391
+ /** Subscribe to the agent-generated chat title; replays the current snapshot. */
392
+ onSessionInfo(fn) {
393
+ sessionInfoListeners.add(fn);
394
+ fn(sessionInfo);
395
+ return () => sessionInfoListeners.delete(fn);
396
+ },
397
+
398
+ /** Subscribe to the raw usage frame (Milestone D); replays the current snapshot (may be null). */
399
+ onUsage(fn) {
400
+ usageListeners.add(fn);
401
+ fn(usage);
402
+ return () => usageListeners.delete(fn);
403
+ },
404
+
405
+ /** Subscribe to the list of currently-pending permission requests; replays the current snapshot. */
406
+ onPermission(fn) {
407
+ permissionListeners.add(fn);
408
+ fn(pendingPermissions);
409
+ return () => permissionListeners.delete(fn);
410
+ },
411
+
412
+ /** Subscribe to the list of currently-pending elicitation requests; replays the current snapshot. */
413
+ onElicitation(fn) {
414
+ elicitationListeners.add(fn);
415
+ fn(pendingElicitations);
416
+ return () => elicitationListeners.delete(fn);
417
+ },
418
+
419
+ /**
420
+ * Answer a pending permission request — `decision` is one of the
421
+ * request's own `options[].optionId`, or `'cancelled'` to reject. Removes
422
+ * it from the pending list optimistically (the server has no separate
423
+ * "resolved" frame; the turn just continues). A response for an id that's
424
+ * no longer pending (already timed out, or a duplicate click) is a no-op.
425
+ */
426
+ async respondPermission(id, decision) {
427
+ if (!pendingPermissions.some((p) => p.id === id)) return;
428
+ pendingPermissions = pendingPermissions.filter((p) => p.id !== id);
429
+ emitPermissions();
430
+ try {
431
+ await ensureOpen();
432
+ ws?.send(JSON.stringify({ t: 'permission-response', id, decision }));
433
+ } catch {
434
+ /* socket unavailable — the bridge's own timeout will deny this request */
435
+ }
436
+ },
437
+
438
+ /**
439
+ * Answer a pending elicitation request — `response` is `{ action, content? }`,
440
+ * `action` one of `'accept'`/`'decline'`/`'cancel'`. Mirrors `respondPermission`
441
+ * exactly, including the optimistic removal + no-op-on-stale-id behavior.
442
+ */
443
+ async respondElicitation(id, response) {
444
+ if (!pendingElicitations.some((p) => p.id === id)) return;
445
+ pendingElicitations = pendingElicitations.filter((p) => p.id !== id);
446
+ emitElicitations();
447
+ try {
448
+ await ensureOpen();
449
+ ws?.send(JSON.stringify({ t: 'elicitation-response', id, ...response }));
450
+ } catch {
451
+ /* socket unavailable — the bridge's own timeout will decline this request */
452
+ }
453
+ },
454
+
455
+ /**
456
+ * Live-set the session mode (Manual/Plan/…) for `chatId` on an
457
+ * ALREADY-established session — no respawn. Fire-and-forget like `warm`;
458
+ * the server validates against its own last-advertised roster and
459
+ * silently ignores an unadvertised id, so a stale/racy pick never corrupts
460
+ * state — the next `caps` frame is the source of truth either way.
461
+ */
462
+ async setMode(chatId, modeId) {
463
+ try {
464
+ await ensureOpen();
465
+ ws?.send(JSON.stringify({ t: 'set-mode', chat: chatId || undefined, modeId }));
466
+ } catch {
467
+ /* socket unavailable — non-fatal */
468
+ }
469
+ },
470
+
471
+ /** Live-set one config option (model/effort/fast/…) for `chatId`. See `setMode`. */
472
+ async setConfig(chatId, configId, value) {
473
+ try {
474
+ await ensureOpen();
475
+ ws?.send(JSON.stringify({ t: 'set-config', chat: chatId || undefined, configId, value }));
476
+ } catch {
477
+ /* socket unavailable — non-fatal */
478
+ }
479
+ },
480
+
266
481
  /**
267
482
  * Warm the adapter WITHOUT prompting so the agent publishes its command
268
483
  * catalogue. Fired when the user starts typing a slash command. Best-effort;
269
- * a dead socket just leaves autocomplete on the static list.
484
+ * a dead socket just leaves autocomplete on the static list. `model`/
485
+ * `effort`/`mode` are the user's PERSISTED picks — applied once, live, by
486
+ * the bridge right after the session establishes (never onto an existing one).
270
487
  */
271
- async warm(chatId, model, effort) {
488
+ async warm(chatId, model, effort, mode) {
272
489
  try {
273
490
  await ensureOpen();
274
491
  ws?.send(
@@ -277,6 +494,7 @@ export function createAcpConnection() {
277
494
  chat: chatId || undefined,
278
495
  model: model || undefined,
279
496
  effort: effort || undefined,
497
+ mode: mode || undefined,
280
498
  })
281
499
  );
282
500
  } catch {
@@ -287,17 +505,25 @@ export function createAcpConnection() {
287
505
  /**
288
506
  * Drive one prompt turn. Async-generates the bridge's `update` frames until
289
507
  * `turn-end`; throws on `error`; sends `cancel` when `abortSignal` aborts.
508
+ * `model`/`effort`/`mode` — see `warm`.
290
509
  */
291
- async *prompt(text, chatId, abortSignal, model, effort) {
292
- await ensureOpen();
510
+ async *prompt(text, chatId, abortSignal, model, effort, mode) {
511
+ try {
512
+ await ensureOpen();
513
+ } catch (err) {
514
+ setError(err?.message || 'Could not reach the Claude bridge.');
515
+ throw err;
516
+ }
293
517
  const queue = [];
294
518
  let wake = null;
295
519
  let ended = false;
296
520
  let failure = null;
297
521
  turnHandler = (frame) => {
298
522
  if (frame.t === 'turn-end') ended = true;
299
- else if (frame.t === 'error') failure = frame.message || 'The Claude bridge errored.';
300
- else queue.push(frame); // update / connected / permission
523
+ else if (frame.t === 'error') {
524
+ failure = frame.message || 'The Claude bridge errored.';
525
+ setError(failure); // Task C3 — the styled retry card's source
526
+ } else queue.push(frame); // update / connected / permission
301
527
  if (wake) {
302
528
  const w = wake;
303
529
  wake = null;
@@ -319,6 +545,7 @@ export function createAcpConnection() {
319
545
  // setBusy(true) fires FIRST so the finished-ping deferral (ChatPanel) sees
320
546
  // busy before these empty emits.
321
547
  resetBackground();
548
+ setError(null); // a fresh turn clears any stale card from a prior failure
322
549
  if (activeTools.size) {
323
550
  activeTools.clear();
324
551
  emitActivity();
@@ -331,6 +558,7 @@ export function createAcpConnection() {
331
558
  chat: chatId || undefined,
332
559
  model: model || undefined,
333
560
  effort: effort || undefined,
561
+ mode: mode || undefined,
334
562
  })
335
563
  );
336
564
  for (;;) {
@@ -369,7 +597,9 @@ export function createAcpConnection() {
369
597
  };
370
598
  }
371
599
 
372
- function lastUserText(messages) {
600
+ /** The last user message's plain text — shared by the adapter's run() AND the
601
+ * connection-problem card's "Try again" (Task C3), which re-sends it verbatim. */
602
+ export function lastUserText(messages) {
373
603
  for (let i = messages.length - 1; i >= 0; i--) {
374
604
  const m = messages[i];
375
605
  if (m.role !== 'user') continue;
@@ -512,7 +742,15 @@ function expandPasteChips(text, map) {
512
742
  * parts, preserving the order in which the agent emits them. `available_commands_update`
513
743
  * and `usage_update` are intentionally dropped (chrome noise, not chat content).
514
744
  */
515
- export function makeAcpAdapter(conn, getChatId, getModel, getEffort, getAttachments, getContext) {
745
+ export function makeAcpAdapter(
746
+ conn,
747
+ getChatId,
748
+ getModel,
749
+ getEffort,
750
+ getAttachments,
751
+ getContext,
752
+ getMode
753
+ ) {
516
754
  return {
517
755
  async *run({ messages, abortSignal }) {
518
756
  // Let any in-flight clipboard-image upload finish so its chip expands to a
@@ -540,7 +778,8 @@ export function makeAcpAdapter(conn, getChatId, getModel, getEffort, getAttachme
540
778
  getChatId(),
541
779
  abortSignal,
542
780
  getModel?.(),
543
- getEffort?.()
781
+ getEffort?.(),
782
+ getMode?.()
544
783
  )) {
545
784
  if (frame.t !== 'update') continue;
546
785
  applyUpdate(parts, toolIndex, frame.update);
@@ -0,0 +1,65 @@
1
+ // Pure parser for the `usage` WS frame (Milestone D — context-window meter +
2
+ // cost + single-window rate-limit banner). Scope is deliberately the genuinely
3
+ // dynamic subset the ACP adapter exposes today (see the plan's Milestone D
4
+ // research): a context-window gauge + session cost (from `usage_update`) and
5
+ // an event-driven SINGLE-window rate-limit signal (`SDKRateLimitInfo` riding
6
+ // `_meta["_claude/rateLimit"]`). The full multi-window "Plan usage limits"
7
+ // panel is explicitly OUT of scope — that data isn't reachable through the
8
+ // pinned adapter version; do not fabricate it here.
9
+
10
+ const RATE_LIMIT_LABELS = {
11
+ five_hour: '5-hour limit',
12
+ seven_day: 'Weekly limit',
13
+ seven_day_opus: 'Weekly · Opus',
14
+ seven_day_sonnet: 'Weekly · Sonnet',
15
+ seven_day_overage_included: 'Weekly (overage included)',
16
+ overage: 'Overage',
17
+ };
18
+
19
+ /** A friendly label for an `SDKRateLimitInfo.rateLimitType` — an unrecognized
20
+ * or missing type falls back to a generic label rather than `undefined`, so
21
+ * a future rate-limit-type the adapter adds still renders something sane. */
22
+ function rateLimitLabel(type) {
23
+ return RATE_LIMIT_LABELS[type] || 'Usage limit';
24
+ }
25
+
26
+ /**
27
+ * `{ used, size, cost?, rateLimit? }` (the bridge's `BridgeUsage` shape, sent
28
+ * verbatim in a `{t:'usage', usage}` frame) → the render-ready shape:
29
+ * { context: { used, size, pct } | null, cost: {amount,currency} | null,
30
+ * rateLimit: { type, label, pct, resetsAt, status } | null, asOf }
31
+ * Tolerant of a missing/malformed frame or `_meta` — always returns a valid,
32
+ * renderable (if mostly-null) shape rather than throwing. `now` defaults to
33
+ * `Date.now()`; callers (tests) can inject a fixed value for determinism.
34
+ */
35
+ export function parseUsage(frame, now = Date.now()) {
36
+ const usage = frame && typeof frame === 'object' ? frame.usage : null;
37
+ const asOf = now;
38
+ if (!usage || typeof usage !== 'object') {
39
+ return { context: null, cost: null, rateLimit: null, asOf };
40
+ }
41
+ const used = typeof usage.used === 'number' ? usage.used : null;
42
+ const size = typeof usage.size === 'number' ? usage.size : null;
43
+ const context =
44
+ used != null && size != null && size > 0
45
+ ? { used, size, pct: Math.max(0, Math.min(100, Math.round((used / size) * 100))) }
46
+ : null;
47
+ const cost =
48
+ usage.cost && typeof usage.cost === 'object' && typeof usage.cost.amount === 'number'
49
+ ? { amount: usage.cost.amount, currency: usage.cost.currency || 'USD' }
50
+ : null;
51
+
52
+ const raw = usage.rateLimit;
53
+ let rateLimit = null;
54
+ if (raw && typeof raw === 'object' && typeof raw.status === 'string') {
55
+ rateLimit = {
56
+ type: raw.rateLimitType || null,
57
+ label: rateLimitLabel(raw.rateLimitType),
58
+ pct: typeof raw.utilization === 'number' ? Math.max(0, Math.min(100, Math.round(raw.utilization))) : null,
59
+ resetsAt: raw.resetsAt || null,
60
+ status: raw.status,
61
+ };
62
+ }
63
+
64
+ return { context, cost, rateLimit, asOf };
65
+ }
@@ -0,0 +1,36 @@
1
+ // Pure transcript view-mode filter (Task C4). Operates on a message's raw
2
+ // content parts BEFORE grouping (ToolGroup.jsx) — a pure, order-preserving
3
+ // filter, never a hardcoded assumption about what part types exist beyond the
4
+ // three the adapter emits (text / reasoning / tool-call).
5
+
6
+ export const TRANSCRIPT_VIEWS = ['normal', 'thinking', 'verbose', 'summary'];
7
+ export const DEFAULT_TRANSCRIPT_VIEW = 'normal';
8
+
9
+ /**
10
+ * `parts` (a message's content array) + `mode` → the parts to actually render.
11
+ * - normal — text + tool-calls; reasoning hidden (today's default look).
12
+ * - thinking — everything, including reasoning ("Thinking" disclosures).
13
+ * - verbose — everything (same set as thinking); the extra "verbose-ness"
14
+ * is a RENDER-detail toggle (see transcriptForcesExpand), not a
15
+ * different part set — tool calls stay visible either way.
16
+ * - summary — only the LAST text part (the final answer), nothing else.
17
+ * An unrecognized mode falls back to `normal` rather than showing everything
18
+ * (fail toward less noise, not more).
19
+ */
20
+ export function filterTranscriptParts(parts, mode) {
21
+ const list = Array.isArray(parts) ? parts : [];
22
+ if (mode === 'summary') {
23
+ for (let i = list.length - 1; i >= 0; i--) {
24
+ if (list[i]?.type === 'text') return [list[i]];
25
+ }
26
+ return [];
27
+ }
28
+ if (mode === 'thinking' || mode === 'verbose') return list.slice();
29
+ return list.filter((p) => p?.type !== 'reasoning'); // normal (and any unknown mode)
30
+ }
31
+
32
+ /** Verbose mode auto-expands tool-call detail (groups start open, cards show
33
+ * raw args/result) instead of requiring a click per card. */
34
+ export function transcriptForcesExpand(mode) {
35
+ return mode === 'verbose';
36
+ }