cli-jaw 2.17.41 → 2.17.42

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 (167) hide show
  1. package/README.md +3 -3
  2. package/dist/bin/commands/browser.js +52 -5
  3. package/dist/bin/commands/browser.js.map +1 -1
  4. package/dist/src/agent/args.js +69 -19
  5. package/dist/src/agent/args.js.map +1 -1
  6. package/dist/src/agent/codex-app-events.js +31 -1
  7. package/dist/src/agent/codex-app-events.js.map +1 -1
  8. package/dist/src/agent/cursor-model-inventory.js +140 -0
  9. package/dist/src/agent/cursor-model-inventory.js.map +1 -0
  10. package/dist/src/agent/grok-models.js +80 -0
  11. package/dist/src/agent/grok-models.js.map +1 -0
  12. package/dist/src/browser/actions.js +383 -20
  13. package/dist/src/browser/actions.js.map +1 -1
  14. package/dist/src/browser/grounding-candidate.js +272 -0
  15. package/dist/src/browser/grounding-candidate.js.map +1 -0
  16. package/dist/src/browser/grounding-eval.js +247 -0
  17. package/dist/src/browser/grounding-eval.js.map +1 -0
  18. package/dist/src/browser/image-size.js +63 -0
  19. package/dist/src/browser/image-size.js.map +1 -0
  20. package/dist/src/browser/index.js +1 -0
  21. package/dist/src/browser/index.js.map +1 -1
  22. package/dist/src/browser/occlusion.js +148 -0
  23. package/dist/src/browser/occlusion.js.map +1 -0
  24. package/dist/src/browser/verify-candidate.js +162 -0
  25. package/dist/src/browser/verify-candidate.js.map +1 -0
  26. package/dist/src/browser/vision-input.js +74 -0
  27. package/dist/src/browser/vision-input.js.map +1 -0
  28. package/dist/src/browser/vision-provider.js +75 -0
  29. package/dist/src/browser/vision-provider.js.map +1 -0
  30. package/dist/src/browser/vision.js +360 -47
  31. package/dist/src/browser/vision.js.map +1 -1
  32. package/dist/src/browser/web-ai/candidate-reconcile.js +14 -5
  33. package/dist/src/browser/web-ai/candidate-reconcile.js.map +1 -1
  34. package/dist/src/cli/claude-model-discovery.js +175 -0
  35. package/dist/src/cli/claude-model-discovery.js.map +1 -0
  36. package/dist/src/cli/claude-models.js +46 -0
  37. package/dist/src/cli/claude-models.js.map +1 -1
  38. package/dist/src/cli/registry-live.js +104 -1
  39. package/dist/src/cli/registry-live.js.map +1 -1
  40. package/dist/src/cli/registry.js +28 -5
  41. package/dist/src/cli/registry.js.map +1 -1
  42. package/dist/src/code-mode/host.js +6 -0
  43. package/dist/src/code-mode/host.js.map +1 -1
  44. package/dist/src/code-mode/manager.js +37 -8
  45. package/dist/src/code-mode/manager.js.map +1 -1
  46. package/dist/src/code-mode/providers/catalog.js +53 -1
  47. package/dist/src/code-mode/providers/catalog.js.map +1 -1
  48. package/dist/src/code-mode/providers/codex-app.js +6 -0
  49. package/dist/src/code-mode/providers/codex-app.js.map +1 -1
  50. package/dist/src/code-mode/providers/live-models.js +85 -0
  51. package/dist/src/code-mode/providers/live-models.js.map +1 -0
  52. package/dist/src/code-mode/providers/provider-live-models.js +120 -0
  53. package/dist/src/code-mode/providers/provider-live-models.js.map +1 -0
  54. package/dist/src/code-mode/session.js +37 -0
  55. package/dist/src/code-mode/session.js.map +1 -1
  56. package/dist/src/prompt/templates/a1-system.md +24 -14
  57. package/dist/src/prompt/templates/control-system.md +6 -11
  58. package/dist/src/prompt/templates/employee.md +5 -5
  59. package/dist/src/routes/browser.js +17 -5
  60. package/dist/src/routes/browser.js.map +1 -1
  61. package/package.json +1 -1
  62. package/public/css/activity.css +20 -1
  63. package/public/css/layout.css +7 -3
  64. package/public/dist/.vite/manifest.json +104 -105
  65. package/public/dist/assets/Agent-CQ4EBgY8.js +1 -0
  66. package/public/dist/assets/CodeCanvas-DWsx6sv5.js +2 -0
  67. package/public/dist/assets/DashboardMeta-Dh-tasbY.js +1 -0
  68. package/public/dist/assets/Display-NrqY_IaN.js +1 -0
  69. package/public/dist/assets/{Permissions-BzbnqenZ.js → Permissions-Bh947tUG.js} +1 -1
  70. package/public/dist/assets/Permissions-DzNJCt8K.js +1 -0
  71. package/public/dist/assets/app-BCBnxiGr.css +1 -0
  72. package/public/dist/assets/{app-x5Kray4W.js → app-Q0prKOFx.js} +4 -4
  73. package/public/dist/assets/{bgtask-badge-W9djRQVu.js → bgtask-badge-DAcWaUfS.js} +1 -1
  74. package/public/dist/assets/bgtask-badge-DHA2OQm8.js +1 -0
  75. package/public/dist/assets/{employees--_dmDw_a.js → employees-CWS3Juca.js} +1 -1
  76. package/public/dist/assets/{iframe-renderer-D4BmLtAq.js → iframe-renderer-DCDKBMb7.js} +2 -2
  77. package/public/dist/assets/iframe-renderer-RpvBly2x.js +1 -0
  78. package/public/dist/assets/manager-Nk7-RYPs.css +1 -0
  79. package/public/dist/assets/manager-XkR-DRaQ.js +5 -0
  80. package/public/dist/assets/manager-shortcuts-jsiX-zV6.js +1 -0
  81. package/public/dist/assets/memory-4YH8sz0L.js +1 -0
  82. package/public/dist/assets/{memory-DENQWzM8.js → memory-DhNdKZLO.js} +1 -1
  83. package/public/dist/assets/{message-history-BsrLcGJj.js → message-history-DbRTl1P0.js} +1 -1
  84. package/public/dist/assets/message-history-lVcLpAw8.js +1 -0
  85. package/public/dist/assets/{render-CF6Q1oIh.js → render-DP8SJtdZ.js} +1 -1
  86. package/public/dist/assets/settings--EGy3_B9.js +1 -0
  87. package/public/dist/assets/settings-85n9NjvX.js +1 -0
  88. package/public/dist/assets/{settings-B-C_jdZR.js → settings-C9_D-pn0.js} +32 -32
  89. package/public/dist/assets/settings-core-8wBl1mg-.js +1 -0
  90. package/public/dist/assets/{settings-core-BzAJLsKN.js → settings-core-BcVD2FgV.js} +1 -1
  91. package/public/dist/assets/{settings-embedding-dEMyByVi.js → settings-embedding-BSpq785l.js} +1 -1
  92. package/public/dist/assets/{settings-embedding-BSA42oKM.css → settings-embedding-LIMq5ebM.css} +1 -1
  93. package/public/dist/assets/{sidebar-fFEOpvJl.js → sidebar-BVhLb5Ft.js} +3 -3
  94. package/public/dist/assets/skills-6GFkHTlQ.js +1 -0
  95. package/public/dist/assets/{skills-Da_b06ci.js → skills-X7qxmHka.js} +1 -1
  96. package/public/dist/assets/{slash-commands-BbPUHHF4.js → slash-commands-BnDH4IU_.js} +1 -1
  97. package/public/dist/assets/slash-commands-qu8fPAZ3.js +1 -0
  98. package/public/dist/assets/{trace-drawer-B-QmzUfP.js → trace-drawer-DasaS1z4.js} +1 -1
  99. package/public/dist/assets/{ui-CkRUrIfp.js → ui-BtiBTXng.js} +1 -1
  100. package/public/dist/assets/ui-BucJ1zMA.js +1 -0
  101. package/public/dist/index.html +2 -2
  102. package/public/dist/manager/index.html +3 -3
  103. package/public/dist/settings/index.html +2 -2
  104. package/public/js/features/settings.ts +16 -0
  105. package/public/manager/src/App.tsx +10 -12
  106. package/public/manager/src/AppChrome.tsx +2 -3
  107. package/public/manager/src/InstancePreview.tsx +47 -0
  108. package/public/manager/src/SidebarRailRouter.tsx +34 -30
  109. package/public/manager/src/code/CodeComposer.tsx +31 -6
  110. package/public/manager/src/code/CodeSessionList.tsx +25 -7
  111. package/public/manager/src/code/CodeToastHost.tsx +49 -0
  112. package/public/manager/src/code/CodeTranscript.tsx +136 -26
  113. package/public/manager/src/code/CodeWorkbench.tsx +16 -2
  114. package/public/manager/src/code/ComposerFooter.tsx +130 -46
  115. package/public/manager/src/code/ContextUsageMeter.tsx +72 -0
  116. package/public/manager/src/code/ProviderGlyph.tsx +58 -0
  117. package/public/manager/src/code/code-composer.css +159 -175
  118. package/public/manager/src/code/code-controller-runtime.ts +3 -1
  119. package/public/manager/src/code/code-session-state.ts +7 -1
  120. package/public/manager/src/code/code-toasts.ts +73 -0
  121. package/public/manager/src/code/code-workbench.css +42 -1
  122. package/public/manager/src/code/code.css +130 -15
  123. package/public/manager/src/code/context-usage.ts +74 -0
  124. package/public/manager/src/code/pending-user-item.ts +46 -0
  125. package/public/manager/src/code/session-order.ts +70 -0
  126. package/public/manager/src/code/tool-summary.ts +101 -0
  127. package/public/manager/src/code/use-dictation.ts +130 -0
  128. package/public/manager/src/components/CommandCenter.tsx +4 -8
  129. package/public/manager/src/components/InstanceDetailPanel.tsx +0 -18
  130. package/public/manager/src/components/Workbench.tsx +6 -10
  131. package/public/manager/src/components/WorkbenchHeader.tsx +0 -11
  132. package/public/manager/src/dashboard-settings/dashboard-settings-ui.ts +3 -1
  133. package/public/manager/src/hooks/useDashboardView.ts +38 -38
  134. package/public/manager/src/manager-components.css +0 -8
  135. package/public/manager/src/manager-shortcut-runner.ts +0 -1
  136. package/public/manager/src/manager-shortcuts.ts +0 -2
  137. package/public/manager/src/settings/pages/Agent.tsx +5 -2
  138. package/public/manager/src/settings/pages/DashboardMeta.tsx +20 -1
  139. package/public/manager/src/settings/pages/Permissions.tsx +23 -4
  140. package/public/manager/src/settings/pages/components/agent/PermissionQuickSection.tsx +6 -2
  141. package/public/manager/src/settings/pages/components/agent/agent-save.ts +24 -1
  142. package/public/manager/src/settings/settings-icons.ts +3 -1
  143. package/public/manager/src/settings/settings-registry.ts +3 -3
  144. package/public/manager/src/settings-controls.css +0 -1
  145. package/public/manager/src/settings-standalone.tsx +8 -8
  146. package/public/manager/src/types.ts +0 -1
  147. package/scripts/check-private-boundary.mjs +101 -0
  148. package/scripts/grounding-eval.mjs +223 -0
  149. package/public/dist/assets/Agent-B8ZEWJ6B.js +0 -1
  150. package/public/dist/assets/CodeCanvas-Bj3lTSu6.js +0 -2
  151. package/public/dist/assets/DashboardMeta-CmZavI4H.js +0 -1
  152. package/public/dist/assets/Display-DFkjKcMk.js +0 -1
  153. package/public/dist/assets/Permissions-CJftCO6p.js +0 -1
  154. package/public/dist/assets/app-DVbrIl8f.css +0 -1
  155. package/public/dist/assets/bgtask-badge-CuAc8bAl.js +0 -1
  156. package/public/dist/assets/iframe-renderer-By2WO5bd.js +0 -1
  157. package/public/dist/assets/manager-CxLXWCGi.css +0 -1
  158. package/public/dist/assets/manager-DA3dF3eN.js +0 -5
  159. package/public/dist/assets/manager-shortcuts-CzGkE0ZG.js +0 -1
  160. package/public/dist/assets/memory-thjr2apY.js +0 -1
  161. package/public/dist/assets/message-history-CfU7_P5t.js +0 -1
  162. package/public/dist/assets/settings-DXA5L4DR.js +0 -1
  163. package/public/dist/assets/settings-Dy_IaNVH.js +0 -1
  164. package/public/dist/assets/settings-core-D7sAQqhC.js +0 -1
  165. package/public/dist/assets/skills-BcIievZp.js +0 -1
  166. package/public/dist/assets/slash-commands-B-5Shayl.js +0 -1
  167. package/public/dist/assets/ui-CJLdMfRx.js +0 -1
@@ -298,9 +298,77 @@
298
298
  min-height: 0;
299
299
  height: 100%;
300
300
  overflow: hidden;
301
+ /* Contains the toast stack: a stacking context, not just a positioning
302
+ origin, so the notice cannot paint over the mobile drawer or its scrim,
303
+ which sit at z-index 21 and 20 on the window. */
304
+ position: relative;
305
+ z-index: 0;
306
+ }
307
+
308
+ /* Notices sit over the transcript, not over the workspace header. The host is a
309
+ zero-height flow element placed directly above the transcript, so "top" means
310
+ the top of the conversation rather than the top of the pane; absolute
311
+ positioning from the pane would land the card inside the header band and
312
+ swallow clicks meant for the workspace picker. The host ignores pointer
313
+ events so it never blocks the text underneath; the cards take them back. */
314
+ .code-toast-host {
315
+ position: relative;
316
+ z-index: 3;
317
+ height: 0;
318
+ overflow: visible;
319
+ display: flex;
320
+ flex-direction: column;
321
+ align-items: center;
322
+ gap: 8px;
323
+ margin: 0 auto;
324
+ max-width: min(560px, calc(100% - 32px));
325
+ pointer-events: none;
326
+ }
327
+ .code-toast-region { display: flex; flex-direction: column; gap: 8px; align-items: center; }
328
+ /* An empty live region has to stay in the DOM to be observed when content
329
+ arrives, but it should not reserve layout while it holds nothing. */
330
+ .code-toast-region:empty { display: none; }
331
+ .code-toast-region:not(:empty) { margin-top: 12px; }
332
+ .code-toast {
333
+ pointer-events: auto;
334
+ display: flex;
335
+ align-items: flex-start;
336
+ gap: 8px;
337
+ padding: 8px 10px;
338
+ border-radius: 10px;
339
+ border: 1px solid var(--border-subtle);
340
+ background: var(--bg-panel);
341
+ box-shadow: 0 6px 20px rgb(0 0 0 / 28%);
342
+ font-size: 12px;
343
+ line-height: 1.35;
344
+ color: var(--text-primary);
345
+ animation: code-toast-enter 0.2s cubic-bezier(0.77, 0, 0.175, 1);
346
+ }
347
+ .code-toast-message { min-width: 0; overflow-wrap: anywhere; }
348
+ .code-toast-dismiss {
349
+ flex-shrink: 0;
350
+ min-width: 20px;
351
+ min-height: 20px;
352
+ padding: 0;
353
+ border: 0;
354
+ border-radius: 5px;
355
+ background: transparent;
356
+ color: var(--text-dim);
357
+ cursor: pointer;
358
+ line-height: 1;
359
+ }
360
+ .code-toast-dismiss:hover { background: var(--bg-raised); color: var(--text-primary); }
361
+ .code-toast-warning { border-color: color-mix(in srgb, var(--status-warn) 48%, var(--border-subtle)); }
362
+ .code-toast-error { border-color: color-mix(in srgb, var(--status-error) 52%, var(--border-subtle)); }
363
+ @keyframes code-toast-enter {
364
+ from { opacity: 0; transform: translateY(-6px); }
365
+ to { opacity: 1; transform: translateY(0); }
366
+ }
367
+ @media (prefers-reduced-motion: reduce) {
368
+ .code-toast { animation: none; }
301
369
  }
302
370
 
303
- /* Session header (title + context meter + plan) */
371
+ /* Session header (title + plan) */
304
372
  .code-session-header {
305
373
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
306
374
  padding: 6px 16px;
@@ -309,19 +377,37 @@
309
377
  font-size: 11px;
310
378
  }
311
379
  .code-session-title { font-weight: 600; color: var(--text-primary); }
312
- .code-context-meter {
313
- width: 80px; height: 6px;
314
- background: var(--bg-raised);
315
- border-radius: 3px;
316
- overflow: hidden;
317
- display: inline-block;
318
- }
319
- .code-context-bar {
320
- display: block; height: 100%;
321
- background: var(--accent);
322
- border-radius: 3px;
323
- transition: width 0.3s ease;
324
- }
380
+ /* Context usage sits in the composer footer, next to the model it belongs to.
381
+ The ring appears only when the runtime reported a window: a proportion of an
382
+ unknown total is not a proportion. */
383
+ .code-context-usage { position: relative; display: inline-flex; align-items: center; }
384
+ .code-context-trigger {
385
+ display: inline-flex; align-items: center; gap: 4px;
386
+ height: 28px; padding: 0 8px;
387
+ border: 1px solid transparent; border-radius: 8px;
388
+ background: transparent; color: var(--text-secondary);
389
+ font-size: 11px; font-variant-numeric: tabular-nums; cursor: pointer;
390
+ }
391
+ .code-context-trigger:hover, .code-context-trigger:focus-visible { background: var(--bg-raised); color: var(--text-primary); }
392
+ .code-context-ring { width: 16px; height: 16px; transform: rotate(-90deg); flex-shrink: 0; }
393
+ .code-context-ring-track { fill: none; stroke: var(--border-subtle); stroke-width: 2.5; }
394
+ .code-context-ring-fill { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; }
395
+ .code-context-trigger.is-high .code-context-ring-fill { stroke: var(--status-warn); }
396
+ .code-context-trigger.is-critical .code-context-ring-fill { stroke: var(--status-error); }
397
+ .code-context-trigger.is-critical .code-context-value { color: var(--status-error); }
398
+ .code-context-detail {
399
+ position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
400
+ z-index: 4; min-width: 180px; padding: 8px 10px;
401
+ border: 1px solid var(--border-subtle); border-radius: 10px;
402
+ background: var(--bg-panel); box-shadow: 0 6px 20px rgb(0 0 0 / 28%);
403
+ font-size: 11px; color: var(--text-secondary);
404
+ }
405
+ .code-context-detail-total { margin: 0 0 4px; color: var(--text-primary); font-weight: 650; }
406
+ .code-context-detail-note { margin: 0 0 6px; }
407
+ .code-context-breakdown { display: grid; gap: 2px; margin: 0; }
408
+ .code-context-breakdown > div { display: flex; justify-content: space-between; gap: 12px; }
409
+ .code-context-breakdown dt, .code-context-breakdown dd { margin: 0; }
410
+ .code-context-breakdown dd { font-variant-numeric: tabular-nums; color: var(--text-primary); }
325
411
  .code-plan-entries { display: flex; gap: 8px; flex-wrap: wrap; }
326
412
  .code-plan-entry { font-size: 10px; color: var(--text-secondary); }
327
413
  .code-plan-completed { color: var(--status-success); }
@@ -382,8 +468,25 @@
382
468
  color: var(--text-secondary);
383
469
  margin-bottom: 4px; display: block;
384
470
  }
385
- .code-message-user .code-message-role { color: var(--text-primary); }
386
471
  .code-message-assistant .code-message-role { color: var(--status-success); }
472
+
473
+ /*
474
+ * The user's own text is the one thing on this surface they wrote themselves,
475
+ * so it gets a right-aligned bubble and the assistant keeps the full column as
476
+ * plain prose. Asymmetry does the role separation that a repeated label
477
+ * otherwise has to do in words.
478
+ */
479
+ .code-message-user { display: flex; flex-direction: column; align-items: flex-end; }
480
+ .code-message-user .code-message-role { color: var(--text-dim); }
481
+ .code-message-user .code-message-text {
482
+ max-width: min(78%, 36rem);
483
+ padding: 10px 14px;
484
+ border: 1px solid var(--border-subtle);
485
+ border-radius: 12px 12px 4px 12px;
486
+ background: var(--bg-raised);
487
+ }
488
+ /* A prompt the server has not echoed yet is dimmed, never hidden. */
489
+ .code-message-user.is-unsent .code-message-text { opacity: 0.72; border-style: dashed; }
387
490
  .code-message-text {
388
491
  font-size: 13px; line-height: 1.6; color: var(--text-primary);
389
492
  }
@@ -494,6 +597,18 @@
494
597
  }
495
598
 
496
599
  .code-session-attention { grid-column: 1 / -1; color: var(--text-secondary); font-size: 10px; }
600
+ .code-session-attention-approvals { color: var(--status-warn); font-weight: 650; }
601
+ /* The uneventful cases stay readable to assistive technology and out of the
602
+ way visually. A label on every row is a label on no row, and it costs the
603
+ one session that is actually waiting its visibility. */
604
+ .code-session-attention[data-quiet], .code-session-status[data-quiet] {
605
+ position: absolute;
606
+ width: 1px; height: 1px;
607
+ margin: -1px; padding: 0;
608
+ overflow: hidden;
609
+ clip-path: inset(50%);
610
+ white-space: nowrap;
611
+ }
497
612
  .code-session-row { margin-bottom: 6px; }
498
613
  .code-session-actions { font-size: 11px; color: var(--text-secondary); margin: 0 8px; }
499
614
  .code-session-actions summary { cursor: pointer; min-height: 24px; }
@@ -0,0 +1,74 @@
1
+ import type { CodeContextUsage } from '../../../../src/code-mode/wire';
2
+
3
+ /**
4
+ * How much of the model's context the conversation is using.
5
+ *
6
+ * The runtime may report a token count without a window, or nothing at all, so
7
+ * there are three answers and not one. A missing window is not an unlimited
8
+ * one and a missing count is not zero, which is why neither collapses into a
9
+ * number the reader would take as measured.
10
+ */
11
+ export type ContextUsageView =
12
+ | { state: 'hidden' }
13
+ | { state: 'counted'; usedTokens: number }
14
+ | { state: 'measured'; usedTokens: number; maxTokens: number; percent: number; over: boolean };
15
+
16
+ export function contextUsageView(usage: CodeContextUsage | undefined): ContextUsageView {
17
+ if (!usage || !Number.isFinite(usage.totalTokens) || usage.totalTokens < 0) return { state: 'hidden' };
18
+ const max = usage.modelContextWindow;
19
+ if (max === null || !Number.isFinite(max) || max <= 0) return { state: 'counted', usedTokens: usage.totalTokens };
20
+ // A count past the window is possible -- a model change moves the window
21
+ // under a conversation that is already large. The ring cannot draw past
22
+ // full, so the percentage is capped for the ring's sake, but the fact that
23
+ // it was capped is carried rather than hidden: a label reading "100% used,
24
+ // 533k of 258k" is self-contradictory, and quietly capping is how a wrong
25
+ // number would look exactly like a full context.
26
+ const raw = Math.round((usage.totalTokens / max) * 100);
27
+ return { state: 'measured', usedTokens: usage.totalTokens, maxTokens: max,
28
+ percent: Math.min(100, raw), over: raw > 100 };
29
+ }
30
+
31
+ /**
32
+ * Rounded to keep the control narrow. Each unit is chosen after its own
33
+ * rounding: 999,999 in thousands rounds to 1000, which is a four-digit "k"
34
+ * that should have been "1.0M", and 999.5 rounds to 1000, which is not the
35
+ * bare small number that branch is for.
36
+ */
37
+ function scaled(value: number, divisor: number): string {
38
+ const n = value / divisor;
39
+ return n < 10 ? n.toFixed(1) : String(Math.round(n));
40
+ }
41
+ export function formatTokens(value: number): string {
42
+ if (!Number.isFinite(value) || value < 0) return '—';
43
+ const rounded = Math.round(value);
44
+ if (rounded < 1000) return String(rounded);
45
+ const thousands = scaled(rounded, 1000);
46
+ return Number(thousands) < 1000 ? `${thousands}k` : `${scaled(rounded, 1_000_000)}M`;
47
+ }
48
+
49
+ /** The count itself, grouped. The rounded form cannot be reconstructed into it. */
50
+ export function formatExactTokens(value: number | null): string {
51
+ return value === null || !Number.isFinite(value) || value < 0 ? '—' : value.toLocaleString('en-US');
52
+ }
53
+
54
+ /** An unreported part of the breakdown reads as absent, never as zero. */
55
+ export function formatOptionalTokens(value: number | null): string {
56
+ return value === null ? '—' : formatTokens(value);
57
+ }
58
+
59
+ /**
60
+ * Thresholds are about when to start paying attention, so they are named rather
61
+ * than left as bare numbers at the call site.
62
+ */
63
+ export function contextUsageLevel(percent: number): 'normal' | 'high' | 'critical' {
64
+ if (percent >= 90) return 'critical';
65
+ if (percent >= 75) return 'high';
66
+ return 'normal';
67
+ }
68
+
69
+ export function contextUsageLabel(view: ContextUsageView): string {
70
+ if (view.state === 'hidden') return '';
71
+ if (view.state === 'counted') return `Context: ${formatTokens(view.usedTokens)} tokens used, window unknown`;
72
+ if (view.over) return `Context: over the window, ${formatTokens(view.usedTokens)} of ${formatTokens(view.maxTokens)} tokens`;
73
+ return `Context: ${view.percent}% used, ${formatTokens(view.usedTokens)} of ${formatTokens(view.maxTokens)} tokens`;
74
+ }
@@ -0,0 +1,46 @@
1
+ import type { CodeItem } from '../../../../src/code-mode/wire';
2
+ import type { CodeDraft } from './code-controller-drafts';
3
+
4
+ /**
5
+ * A submitted prompt used to vanish between Send and the server's echo.
6
+ *
7
+ * The server does emit a `user_message` as soon as it accepts a turn, but that
8
+ * arrives after an HTTP round trip and an SSE hop, and when acceptance is
9
+ * unknown it may never arrive at all. Until then the transcript showed nothing,
10
+ * so the text the user just wrote was simply gone from the screen.
11
+ *
12
+ * This is derived at read time rather than pushed into the session reducer:
13
+ * that reducer applies strictly sequence-ordered server events, and inserting a
14
+ * client-invented item would desynchronize its cursor. `clientTurnKey` is the
15
+ * join key, so the real item replaces this one the moment it lands.
16
+ */
17
+ export const PENDING_USER_ITEM_ID = 'pending:user';
18
+
19
+ export function pendingUserItem(draft: CodeDraft, items: CodeItem[], now = Date.now()): CodeItem | null {
20
+ const retry = draft.retry;
21
+ if (!retry) return null;
22
+ // The authoritative item is here: show that one instead.
23
+ if (items.some(item => item.kind === 'user_message' && item.clientTurnKey === retry.key)) return null;
24
+ const unknown = draft.operation.kind === 'unknown-send';
25
+ return {
26
+ itemId: PENDING_USER_ITEM_ID,
27
+ turnId: null,
28
+ kind: 'user_message',
29
+ // An unconfirmed send is not a failure and must not read as one; it is
30
+ // still pending until the user retries or the echo arrives.
31
+ status: 'pending',
32
+ text: retry.text,
33
+ clientTurnKey: retry.key,
34
+ // Sorting is by firstSequence, so a large value keeps this last.
35
+ firstSequence: Number.MAX_SAFE_INTEGER,
36
+ createdAt: now,
37
+ updatedAt: now,
38
+ ...(unknown ? { phase: 'unknown' as const } : {}),
39
+ };
40
+ }
41
+
42
+ /** Items to render: server history plus the not-yet-echoed prompt. */
43
+ export function withPendingUserItem(draft: CodeDraft, items: CodeItem[], now = Date.now()): CodeItem[] {
44
+ const pending = pendingUserItem(draft, items, now);
45
+ return pending ? [...items, pending] : items;
46
+ }
@@ -0,0 +1,70 @@
1
+ import type { CodeSessionInfo } from '../../../../src/code-mode/wire';
2
+
3
+ /**
4
+ * How the session list is ordered and grouped, kept apart from rendering so the
5
+ * rules can be stated and tested on their own.
6
+ *
7
+ * The server returns sessions by `last_used_at DESC`, which means answering a
8
+ * prompt in the third session moves it to the top while the reader is looking
9
+ * at it. Order here is anchored to creation instead: a row holds its place
10
+ * until something actually changes about it, so the list moves when a session
11
+ * is created or archived and not merely because it is busy. Which session is
12
+ * running is carried by the row's own status, which is where a changing fact
13
+ * belongs.
14
+ */
15
+ export type CodeSessionSection = 'active' | 'archived';
16
+
17
+ export function codeSessionSection(session: CodeSessionInfo): CodeSessionSection {
18
+ return session.archivedAt === null ? 'active' : 'archived';
19
+ }
20
+
21
+ /** Creation order, newest first. Ties break on id so the order is total. */
22
+ export function compareCodeSessions(left: CodeSessionInfo, right: CodeSessionInfo): number {
23
+ return right.createdAt - left.createdAt || left.sessionId.localeCompare(right.sessionId);
24
+ }
25
+
26
+ export type CodeSessionGroup = {
27
+ section: CodeSessionSection;
28
+ sessions: CodeSessionInfo[];
29
+ };
30
+
31
+ /**
32
+ * Active first, then archived. Archived sessions are history: they are ordered
33
+ * by when they were archived, because "when did I put this away" is the
34
+ * question that section answers, and they fall back to creation order when a
35
+ * record predates the field.
36
+ */
37
+ export function groupCodeSessions(sessions: readonly CodeSessionInfo[]): CodeSessionGroup[] {
38
+ const active = sessions.filter(session => codeSessionSection(session) === 'active').sort(compareCodeSessions);
39
+ const archived = sessions.filter(session => codeSessionSection(session) === 'archived')
40
+ .sort((left, right) => (right.archivedAt ?? right.createdAt) - (left.archivedAt ?? left.createdAt)
41
+ || left.sessionId.localeCompare(right.sessionId));
42
+ const groups: CodeSessionGroup[] = [];
43
+ if (active.length) groups.push({ section: 'active', sessions: active });
44
+ if (archived.length) groups.push({ section: 'archived', sessions: archived });
45
+ return groups;
46
+ }
47
+
48
+ /**
49
+ * What a row says about itself, beyond its title.
50
+ *
51
+ * Idle is the common case and says nothing: a label on every row is a label on
52
+ * no row, and it costs the one session that is actually waiting its visibility.
53
+ * Unknown stays unknown -- an unhydrated approval count is not zero approvals.
54
+ */
55
+ export type CodeSessionAttention =
56
+ | { kind: 'none' }
57
+ | { kind: 'unknown' }
58
+ | { kind: 'approvals'; count: number };
59
+
60
+ export function codeSessionAttention(count: number | undefined): CodeSessionAttention {
61
+ if (count === undefined) return { kind: 'unknown' };
62
+ return count > 0 ? { kind: 'approvals', count } : { kind: 'none' };
63
+ }
64
+
65
+ export function codeSessionAttentionLabel(attention: CodeSessionAttention): string {
66
+ if (attention.kind === 'unknown') return 'Approval status unknown';
67
+ if (attention.kind === 'none') return 'No pending approvals';
68
+ return `${attention.count} pending approval${attention.count === 1 ? '' : 's'}`;
69
+ }
70
+
@@ -0,0 +1,101 @@
1
+ import type { CodeItem } from '../../../../src/code-mode/wire';
2
+
3
+ /**
4
+ * One line that says what a tool call did, in the user's terms.
5
+ *
6
+ * A raw tool name plus a JSON blob forces the reader to decode the call before
7
+ * they can tell whether it matters. `Read src/app.ts` is legible at a glance,
8
+ * so the body can stay collapsed until someone actually wants it.
9
+ */
10
+ /**
11
+ * [pattern, settled, active]. The settled word is what a finished call reads as
12
+ * and is deliberately unchanged: it is asserted across the summary tests and is
13
+ * the vocabulary the transcript has always used.
14
+ *
15
+ * The active word exists because a running call and a finished one otherwise
16
+ * look identical once the status badge is gone. Tense carries completion, the
17
+ * way it does in a native transcript, instead of a chip that says "Done" on
18
+ * every successful row.
19
+ */
20
+ const VERBS: Array<[RegExp, string, string]> = [
21
+ [/^(read|read_file|get_file|open_file|view|cat)$/i, 'Read', 'Reading'],
22
+ [/^(write|write_file|create_file|edit|edit_file|apply_patch|str_replace\w*|update_file)$/i, 'Edit', 'Editing'],
23
+ [/^(bash|shell|exec|exec_command|run|run_command|terminal)$/i, 'Bash', 'Running'],
24
+ [/^(search|grep|rg|ripgrep|glob|find|codebase_search)$/i, 'Search', 'Searching'],
25
+ [/^(fetch|browser\w*|web_\w*|open_url|http)$/i, 'Open', 'Opening'],
26
+ [/^(list|ls|list_dir|list_files)$/i, 'List', 'Listing'],
27
+ ];
28
+
29
+ /** A call that has not settled yet reads in the present tense. */
30
+ function isActive(status: CodeItem['status']): boolean {
31
+ return status === 'running' || status === 'pending';
32
+ }
33
+
34
+ /** Longest-suffix workspace-relative path, so the row stays scannable. */
35
+ export function shortenPath(value: string, workingDir: string): string {
36
+ const trimmed = value.trim();
37
+ if (workingDir && trimmed.startsWith(workingDir)) {
38
+ const relative = trimmed.slice(workingDir.length).replace(/^[\\/]+/, '');
39
+ if (relative) return relative;
40
+ }
41
+ if (trimmed.length <= 64) return trimmed;
42
+ const parts = trimmed.split(/[\\/]/).filter(Boolean);
43
+ return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : `…${trimmed.slice(-60)}`;
44
+ }
45
+
46
+ function firstLine(value: string): string {
47
+ const line = value.split(/\r?\n/).find(row => row.trim()) ?? '';
48
+ const collapsed = line.replace(/\s+/g, ' ').trim();
49
+ return collapsed.length > 96 ? `${collapsed.slice(0, 95)}…` : collapsed;
50
+ }
51
+
52
+ /**
53
+ * A tool argument may be a JSON object, a bare command, or a path. Pull the
54
+ * one field a reader would recognise, and fall back to the first line rather
55
+ * than printing an object.
56
+ */
57
+ export function summariseToolInput(input: string | undefined, workingDir: string): string {
58
+ if (!input) return '';
59
+ const raw = input.trim();
60
+ if (!raw) return '';
61
+ if (raw.startsWith('{')) {
62
+ try {
63
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
64
+ for (const key of ['command', 'cmd', 'path', 'file_path', 'file', 'url', 'query', 'pattern']) {
65
+ const value = parsed[key];
66
+ if (typeof value === 'string' && value.trim()) {
67
+ return key === 'path' || key === 'file' || key === 'file_path'
68
+ ? shortenPath(value, workingDir) : firstLine(value);
69
+ }
70
+ }
71
+ return '';
72
+ } catch { /* not JSON after all; fall through to the raw text */ }
73
+ }
74
+ return raw.includes('/') && !raw.includes(' ') ? shortenPath(raw, workingDir) : firstLine(raw);
75
+ }
76
+
77
+ export function toolSummary(item: CodeItem, workingDir: string): string {
78
+ const active = isActive(item.status);
79
+ if (item.kind === 'file_change') {
80
+ const target = summariseToolInput(item.tool?.input, workingDir) || item.tool?.detail || '';
81
+ const verb = active ? 'Editing' : 'Edit';
82
+ return target ? `${verb} ${target}` : 'File change';
83
+ }
84
+ const name = item.tool?.name?.trim() || 'Tool';
85
+ const match = VERBS.find(([pattern]) => pattern.test(name));
86
+ const verb = match ? (active ? match[2] : match[1]) : undefined;
87
+ const detail = summariseToolInput(item.tool?.input, workingDir);
88
+ if (verb) return detail ? `${verb} ${detail}` : verb;
89
+ // An unrecognised tool keeps its own name; renaming it would hide which
90
+ // tool actually ran, including MCP tools whose names are the useful part.
91
+ return detail ? `${name} ${detail}` : name;
92
+ }
93
+
94
+ /** Only states worth interrupting the reader for. `done` is the default. */
95
+ export function noteworthyStatus(item: CodeItem): string | null {
96
+ if (item.kind === 'turn_cancelled' || item.status === 'cancelled') return 'Stopped';
97
+ if (item.kind === 'turn_failed' || item.status === 'error') return 'Failed';
98
+ if (item.status === 'running') return 'Running';
99
+ if (item.status === 'pending') return 'Pending';
100
+ return null;
101
+ }
@@ -0,0 +1,130 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * Composer dictation over the existing `/api/voice` endpoint.
5
+ *
6
+ * The route already transcribes an uploaded blob; `x-stt-only` makes it return
7
+ * the text instead of submitting it as a chat message, which is what a composer
8
+ * needs. Nothing here is speculative: if the browser cannot record, the control
9
+ * reports why and stays disabled rather than pretending to listen.
10
+ */
11
+ export type DictationStatus = 'unsupported' | 'idle' | 'recording' | 'transcribing';
12
+
13
+ export interface Dictation {
14
+ status: DictationStatus;
15
+ /** Why the control is disabled, or what pressing it will do. */
16
+ reason: string;
17
+ error: string | null;
18
+ toggle(): void;
19
+ }
20
+
21
+ function pickMime(): string {
22
+ if (typeof MediaRecorder === 'undefined') return '';
23
+ for (const candidate of ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus']) {
24
+ if (MediaRecorder.isTypeSupported(candidate)) return candidate;
25
+ }
26
+ return '';
27
+ }
28
+
29
+ function extensionFor(mime: string): string {
30
+ if (mime.includes('mp4')) return '.mp4';
31
+ if (mime.includes('ogg')) return '.ogg';
32
+ return '.webm';
33
+ }
34
+
35
+ function describe(error: unknown): string {
36
+ const name = (error as DOMException | undefined)?.name;
37
+ if (name === 'NotAllowedError') return 'Microphone access was denied.';
38
+ if (name === 'NotFoundError') return 'No microphone was found.';
39
+ if (name === 'NotReadableError' || name === 'AbortError') return 'The microphone is in use by another application.';
40
+ return error instanceof Error ? error.message : 'Dictation failed.';
41
+ }
42
+
43
+ export function useDictation(onText: (text: string) => void, disabled: boolean): Dictation {
44
+ const [status, setStatus] = useState<DictationStatus>('unsupported');
45
+ const [error, setError] = useState<string | null>(null);
46
+ const recorder = useRef<MediaRecorder | null>(null);
47
+ const stream = useRef<MediaStream | null>(null);
48
+ const chunks = useRef<Blob[]>([]);
49
+ const live = useRef(true);
50
+
51
+ useEffect(() => {
52
+ // Capability is checked, never assumed: a plain-HTTP origin other than
53
+ // localhost has no mediaDevices at all.
54
+ const supported = typeof MediaRecorder !== 'undefined'
55
+ && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
56
+ setStatus(supported ? 'idle' : 'unsupported');
57
+ return () => {
58
+ live.current = false;
59
+ recorder.current?.state === 'recording' && recorder.current.stop();
60
+ stream.current?.getTracks().forEach(track => track.stop());
61
+ };
62
+ }, []);
63
+
64
+ const transcribe = useCallback(async (blob: Blob, mime: string) => {
65
+ setStatus('transcribing');
66
+ try {
67
+ const response = await fetch('/api/voice', {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': mime || 'audio/webm', 'X-Voice-Ext': extensionFor(mime), 'X-Stt-Only': 'true' },
70
+ body: blob,
71
+ });
72
+ const payload = await response.json().catch(() => null) as { text?: string; error?: string } | null;
73
+ if (!response.ok) throw new Error(payload?.error || `Transcription failed (${response.status}).`);
74
+ const text = payload?.text?.trim();
75
+ if (!live.current) return;
76
+ if (text) onText(text);
77
+ else setError('Nothing was transcribed.');
78
+ } catch (err) {
79
+ if (live.current) setError(describe(err));
80
+ } finally {
81
+ if (live.current) setStatus('idle');
82
+ }
83
+ }, [onText]);
84
+
85
+ const toggle = useCallback(() => {
86
+ if (status === 'unsupported' || status === 'transcribing' || disabled) return;
87
+ if (status === 'recording') {
88
+ recorder.current?.stop();
89
+ return;
90
+ }
91
+ setError(null);
92
+ void (async () => {
93
+ let captured: MediaStream;
94
+ try { captured = await navigator.mediaDevices.getUserMedia({ audio: true }); }
95
+ catch (err) { setError(describe(err)); return; }
96
+ if (!live.current) { captured.getTracks().forEach(track => track.stop()); return; }
97
+ stream.current = captured;
98
+ const mime = pickMime();
99
+ let active: MediaRecorder;
100
+ try { active = new MediaRecorder(captured, mime ? { mimeType: mime } : {}); }
101
+ catch (err) {
102
+ captured.getTracks().forEach(track => track.stop());
103
+ setError(describe(err));
104
+ return;
105
+ }
106
+ recorder.current = active;
107
+ chunks.current = [];
108
+ active.ondataavailable = event => { if (event.data.size > 0) chunks.current.push(event.data); };
109
+ active.onerror = () => { setError('Recording failed.'); };
110
+ active.onstop = () => {
111
+ captured.getTracks().forEach(track => track.stop());
112
+ stream.current = null;
113
+ recorder.current = null;
114
+ const blob = new Blob(chunks.current, { type: mime || 'audio/webm' });
115
+ chunks.current = [];
116
+ if (!live.current) return;
117
+ if (blob.size === 0) { setStatus('idle'); setError('Nothing was recorded.'); return; }
118
+ void transcribe(blob, mime);
119
+ };
120
+ active.start();
121
+ setStatus('recording');
122
+ })();
123
+ }, [disabled, status, transcribe]);
124
+
125
+ const reason = status === 'unsupported'
126
+ ? 'Dictation needs microphone access, which this browser context does not provide.'
127
+ : status === 'recording' ? 'Stop dictation'
128
+ : status === 'transcribing' ? 'Transcribing…' : 'Dictation';
129
+ return { status, reason, error, toggle };
130
+ }
@@ -1,5 +1,4 @@
1
- import { Children, cloneElement, isValidElement, type ReactNode } from 'react';
2
- import { ThemeSwitch } from './ThemeSwitch';
1
+ import { type ReactNode } from 'react';
3
2
  import { isElectron } from '../panels/desktop-bridge';
4
3
 
5
4
  type CommandCenterProps = {
@@ -11,12 +10,9 @@ type CommandCenterProps = {
11
10
 
12
11
  export function CommandCenter(props: CommandCenterProps) {
13
12
  const electron = isElectron();
14
- // Keep the command-bar slot adjacent to the theme control without moving
15
- // instance navigation state out of its existing owner.
16
- const actions = isValidElement<{ children?: ReactNode }>(props.actions)
17
- ? cloneElement(props.actions, {}, Children.toArray(props.actions.props.children).flatMap<ReactNode>(child =>
18
- isValidElement(child) && child.type === ThemeSwitch ? [<span key="settings" id="command-settings-slot" />, child] : [child]))
19
- : props.actions;
13
+ // The settings portal slot existed only for the workbench gear, which is gone: the rail
14
+ // owns settings now. Actions pass through untouched.
15
+ const actions = props.actions;
20
16
  return (
21
17
  <div className={`command-center command-bar${electron ? ' is-electron-titlebar' : ''}`}>
22
18
  <div className="command-primary">
@@ -4,7 +4,6 @@ import type {
4
4
  DashboardRegistryInstance,
5
5
  DashboardScanResult,
6
6
  } from '../types';
7
- import { SettingsShell } from '../settings/SettingsShell';
8
7
  import { ProcessControlPanel } from './ProcessControlPanel';
9
8
  import { InstanceLogsPanel } from './InstanceLogsPanel';
10
9
 
@@ -13,8 +12,6 @@ type InstanceDetailPanelProps = {
13
12
  data: DashboardScanResult | null;
14
13
  activeTab: DashboardDetailTab;
15
14
  onRegistryPatch: (port: number, patch: Partial<DashboardRegistryInstance>) => void;
16
- onSettingsDirtyChange?: (dirty: boolean) => void;
17
- onSettingsSaved?: () => void;
18
15
  };
19
16
 
20
17
  export function InstanceDetailPanel(props: InstanceDetailPanelProps) {
@@ -47,21 +44,6 @@ export function InstanceDetailPanel(props: InstanceDetailPanelProps) {
47
44
  </div>
48
45
  )}
49
46
 
50
- {props.activeTab === 'settings' && instance && (
51
- <SettingsShell
52
- key={instance.port}
53
- port={instance.port}
54
- instanceUrl={instance.url}
55
- {...(props.onSettingsDirtyChange !== undefined ? { onDirtyChange: props.onSettingsDirtyChange } : {})}
56
- {...(props.onSettingsSaved !== undefined ? { onSaved: props.onSettingsSaved } : {})}
57
- />
58
- )}
59
-
60
- {props.activeTab === 'settings' && !instance && (
61
- <div className="detail-empty">
62
- Select an instance to configure it.
63
- </div>
64
- )}
65
47
  </section>
66
48
  );
67
49
  }