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
@@ -1,11 +1,49 @@
1
- import { lazy, Suspense, useCallback, useRef, useState, type KeyboardEvent } from 'react';
2
- import type { CodeItem, CodeProviderId } from '../../../../src/code-mode/wire';
1
+ import { lazy, Suspense, useCallback, useMemo, useRef, useState, type KeyboardEvent } from 'react';
2
+ import type { CodeItem, CodeItemKind, CodeProviderId } from '../../../../src/code-mode/wire';
3
3
  import { useCodeTranscriptVirtualRows } from './useCodeTranscriptVirtualRows';
4
4
  import { useCodeTranscriptScroll } from './use-code-transcript-scroll';
5
5
  import { useThrottledMarkdown } from './use-throttled-markdown';
6
6
  import { CODE_RUNTIME_LABELS, codeItemStatus } from './code-types';
7
+ import { noteworthyStatus, toolSummary } from './tool-summary';
8
+ import { PENDING_USER_ITEM_ID } from './pending-user-item';
7
9
 
8
10
  const MarkdownRenderer = lazy(() => import('../notes/rendering/MarkdownRenderer').then(m => ({ default: m.MarkdownRenderer })));
11
+
12
+ /**
13
+ * Turn boundaries are bookkeeping, not conversation. A one-line answer framed by
14
+ * "Turn started" and "Completed" reads as a status board, and neither line tells
15
+ * the reader anything the answer itself does not.
16
+ *
17
+ * They stay in the store and on the wire: history replay, the failed-input
18
+ * recovery lookup in CodeWorkbench and the transcript-limit accounting all still
19
+ * see them. Only this view drops them. Failure and cancellation are kept,
20
+ * because those are states a reader can act on.
21
+ */
22
+ const HIDDEN_KINDS: ReadonlySet<CodeItemKind> = new Set<CodeItemKind>(['turn_started', 'turn_completed']);
23
+
24
+ /**
25
+ * What a collapsible row measures before it has been measured. Collapsed is one
26
+ * line and exact. Expanded is a guess and deliberately only that: the panes are
27
+ * capped at 200px each by `.code-tool-output` / `.code-tool-args`, so a call
28
+ * with both is far taller, and the real number arrives from the ResizeObserver
29
+ * a frame later. This only has to be closer than one line.
30
+ */
31
+ const COLLAPSED_ROW_PX = 44;
32
+ const EXPANDED_ROW_PX = 320;
33
+ /** Same bound the scroll anchors use, for the same reason. */
34
+ const MAX_OPEN_ROWS = 64;
35
+
36
+ /**
37
+ * A failed call opens itself, because the reader has to see why it failed. That
38
+ * is a default, not a lock: once the reader has said what they want for this
39
+ * row, their choice wins, including closing a failure they have already read.
40
+ */
41
+ function isRowOpen(chosen: ReadonlyMap<string, boolean>, sessionKey: string, item: CodeItem): boolean {
42
+ const choice = chosen.get(`${sessionKey}:${item.itemId}`);
43
+ if (choice !== undefined) return choice;
44
+ return item.status === 'error' && item.kind !== 'reasoning';
45
+ }
46
+
9
47
  function ItemMarkdown({ item, identity, onOpenLocalFile }: { item: CodeItem; identity: string; onOpenLocalFile?: ((path: string) => void) | undefined }) {
10
48
  const text = useThrottledMarkdown(item.text ?? '', item.status !== 'running' && item.status !== 'pending', identity);
11
49
  if (!text.trim()) return <span className="code-plain-text">{text}</span>;
@@ -14,32 +52,57 @@ function ItemMarkdown({ item, identity, onOpenLocalFile }: { item: CodeItem; ide
14
52
  </Suspense>;
15
53
  }
16
54
 
17
- export function CodeTranscriptItem({ item, provider, sessionKey, onOpenLocalFile }: {
18
- item: CodeItem; provider: CodeProviderId; sessionKey: string; onOpenLocalFile?: ((path: string) => void) | undefined;
55
+ export function CodeTranscriptItem({ item, provider, sessionKey, workingDir = '', onOpenLocalFile, expanded, onExpandedChange }: {
56
+ item: CodeItem; provider: CodeProviderId; sessionKey: string; workingDir?: string;
57
+ onOpenLocalFile?: ((path: string) => void) | undefined;
58
+ expanded?: boolean;
59
+ onExpandedChange?: ((itemId: string, open: boolean) => void) | undefined;
19
60
  }) {
20
61
  const tool = item.kind === 'tool_call' || item.kind === 'file_change';
21
62
  const reasoning = item.kind === 'reasoning';
22
63
  const assistant = item.kind === 'assistant_message';
23
64
  const user = item.kind === 'user_message';
24
65
  const status = codeItemStatus(item);
66
+ // A completed turn should read as prose, not as a status board: only
67
+ // states the reader can act on are worth a visible badge.
68
+ const note = noteworthyStatus(item);
69
+ const unsent = item.itemId === PENDING_USER_ITEM_ID;
70
+ // The transcript owns the disclosure, because the virtualizer unmounts rows
71
+ // and per-row state would be lost on scroll. Rendered standalone (tests, a
72
+ // future embed) it falls back to the same failure default.
73
+ const open = expanded ?? (item.status === 'error' && item.kind !== 'reasoning');
74
+ const running = item.status === 'running' || item.status === 'pending';
75
+ // The verb already says the call is in flight; a second badge next to
76
+ // "Reading src/app.ts" is the same fact twice. Keyed off the status rather
77
+ // than the badge text, so renaming either vocabulary cannot quietly bring
78
+ // the duplicate back. Failure and cancellation still get a word, because
79
+ // those change what to do next.
80
+ const toolNote = running ? null : note;
25
81
  const label = user ? 'You' : assistant ? CODE_RUNTIME_LABELS[provider] : reasoning ? 'Reasoning'
26
82
  : item.kind === 'turn_started' ? 'Turn started' : item.kind === 'session_runtime' ? 'Runtime'
27
83
  : item.kind === 'permission_request' ? 'Permission record' : item.kind === 'notice' ? 'Notice' : status;
28
- return <article className={`code-message code-message-${tool ? 'tool' : assistant ? 'assistant' : user ? 'user' : 'system'} is-${item.status}`}
84
+ return <article className={`code-message code-message-${tool ? 'tool' : assistant ? 'assistant' : user ? 'user' : 'system'} is-${item.status}${unsent ? ' is-unsent' : ''}`}
29
85
  data-code-item-id={item.itemId} aria-label={`${label} · ${status}`}>
30
- {tool ? <details className={`code-tool-card code-tool-${item.status}`}>
86
+ {tool ? <details className={`code-tool-card code-tool-${item.status}`} open={open}
87
+ onToggle={event => onExpandedChange?.(item.itemId, event.currentTarget.open)}>
31
88
  <summary className="code-tool-summary"><span className="code-tool-chevron" aria-hidden="true">›</span>
32
- <span className="code-tool-name">{item.tool?.name ?? (item.kind === 'file_change' ? 'File change' : 'Tool')}</span>
33
- <span className="code-tool-status">{status}</span></summary>
34
- {item.tool?.detail !== undefined && <p className="code-tool-text">{item.tool.detail}</p>}
35
- {item.tool?.input !== undefined && <section className="code-tool-section"><span className="code-tool-section-label">Input</span><pre className="code-tool-args">{item.tool.input}</pre></section>}
36
- {item.tool?.output !== undefined && <section className="code-tool-section"><span className="code-tool-section-label">Output</span><pre className="code-tool-output">{item.tool.output}</pre></section>}
37
- {item.text !== undefined && <pre className="code-tool-text">{item.text}</pre>}
38
- </details> : reasoning ? <details className="code-thinking">
39
- <summary className="code-thinking-summary">Reasoning · {status}</summary><div className="code-thinking-text">{item.text}</div>
89
+ <span className={`code-tool-name${running ? ' code-tool-name-running' : ''}`}>{toolSummary(item, workingDir)}</span>
90
+ {toolNote && <span className="code-tool-status">{toolNote}</span>}</summary>
91
+ {/* Built only while open: a collapsed call's output can be megabytes,
92
+ and constructing it costs the same whether or not it is painted. */}
93
+ {open && <>
94
+ {item.tool?.detail !== undefined && <p className="code-tool-text">{item.tool.detail}</p>}
95
+ {item.tool?.input !== undefined && <section className="code-tool-section"><span className="code-tool-section-label">Input</span><pre className="code-tool-args">{item.tool.input}</pre></section>}
96
+ {item.tool?.output !== undefined && <section className="code-tool-section"><span className="code-tool-section-label">Output</span><pre className="code-tool-output">{item.tool.output}</pre></section>}
97
+ {item.text !== undefined && <pre className="code-tool-text">{item.text}</pre>}
98
+ </>}
99
+ </details> : reasoning ? <details className="code-thinking" open={open}
100
+ onToggle={event => onExpandedChange?.(item.itemId, event.currentTarget.open)}>
101
+ <summary className={`code-thinking-summary${item.status === 'running' ? ' code-tool-name-running' : ''}`}>{item.status === 'running' ? 'Thinking…' : 'Reasoning'}</summary>
102
+ {open && <div className="code-thinking-text">{item.text}</div>}
40
103
  </details> : <>
41
- <span className="code-message-role">{label}{assistant && item.phase && item.phase !== 'unknown' ? ` · ${item.phase === 'final' ? 'Final' : 'Commentary'}` : ''}
42
- {(assistant || user || item.kind === 'permission_request') && ` · ${status}`}</span>
104
+ <span className="code-message-role">{label}{assistant && item.phase === 'commentary' ? ' · Commentary' : ''}
105
+ {note && ` · ${unsent ? 'Sending' : note}`}</span>
43
106
  <div className="code-message-text">{assistant ? <ItemMarkdown item={item} identity={`${sessionKey}:${item.itemId}`} onOpenLocalFile={onOpenLocalFile} />
44
107
  : <span className="code-plain-text">{item.text ?? item.permission?.title ?? ''}</span>}</div>
45
108
  {item.permission?.detail && <p className="code-plain-text">{item.permission.detail}</p>}
@@ -55,18 +118,62 @@ export function CodeTranscript({ items, provider, sessionKey, workingDir, loadin
55
118
  onOpenLocalFile?: ((path: string) => void) | undefined;
56
119
  }) {
57
120
  const transcriptRef = useRef<HTMLDivElement>(null);
58
- const itemsRef = useRef(items); itemsRef.current = items;
121
+ const visible = useMemo(() => items.filter(item => !HIDDEN_KINDS.has(item.kind)), [items]);
122
+ const itemsRef = useRef(visible); itemsRef.current = visible;
123
+ // What the reader chose, which is not the same as what is open: a failed
124
+ // call opens itself, so "not in the map" and "the reader closed it" have to
125
+ // be different states or the auto-open reopens it on the next render.
126
+ // Scoped by session so the reserved pending-user id cannot carry one
127
+ // session's disclosure into another, and bounded so a long-lived tab does
128
+ // not accumulate ids for sessions it will never show again.
129
+ const [openRows, setOpenRows] = useState<ReadonlyMap<string, boolean>>(() => new Map());
130
+ // `getItemKey` is called by the virtualizer outside render, so it reads the
131
+ // choice through a ref; the callback identity is refreshed below so the
132
+ // virtualizer actually re-keys after a toggle.
133
+ const openRef = useRef(openRows); openRef.current = openRows;
59
134
  const [historyPending, setHistoryPending] = useState(false);
60
135
  const [historyError, setHistoryError] = useState<{ sessionKey: string; message: string } | null>(null);
61
136
  const historyGuard = useRef(false);
62
- const firstId = items[0]?.itemId;
63
- const getItemKey = useCallback((index: number) => `${sessionKey}:${itemsRef.current[index]?.itemId ?? index}`, [sessionKey, firstId]);
137
+ const firstId = visible[0]?.itemId;
138
+ // The disclosure is part of the key, because a measured size always beats an
139
+ // estimate: without it a row measured while collapsed would keep that height
140
+ // after it expands, and no better estimate could correct it. Opening a row
141
+ // gives it a key that has never been measured, so it falls back to the
142
+ // estimate until the ResizeObserver reports the real height. The identity
143
+ // that has to stay stable is the DOM node's, which comes from the React
144
+ // `key` on the row, and that is unchanged.
145
+ const getItemKey = useCallback((index: number) => {
146
+ const item = itemsRef.current[index];
147
+ if (!item) return `${sessionKey}:${index}`;
148
+ return `${sessionKey}:${item.itemId}${isRowOpen(openRef.current, sessionKey, item) ? ':open' : ''}`;
149
+ }, [sessionKey, firstId, openRows]);
64
150
  const estimateSize = useCallback((index: number) => {
65
151
  const item = itemsRef.current[index];
66
- return item?.kind === 'tool_call' || item?.kind === 'reasoning' ? 64 : 64 + Math.min(420, (item?.text?.length ?? 0) / 6);
67
- }, []);
68
- const virtual = useCodeTranscriptVirtualRows({ count: items.length, resetKey: sessionKey, scrollElementRef: transcriptRef, getItemKey, estimateSize });
69
- const { showJump, jumpToLatest } = useCodeTranscriptScroll({ items, sessionKey, transcriptRef, virtual });
152
+ const collapsible = item?.kind === 'tool_call' || item?.kind === 'file_change' || item?.kind === 'reasoning';
153
+ // A collapsible row is one line until someone opens it. Estimating an
154
+ // open row at one line is what makes the scrollbar disagree with the
155
+ // content, so the estimate has to follow the disclosure.
156
+ if (!collapsible) return 64 + Math.min(420, (item?.text?.length ?? 0) / 6);
157
+ return item && isRowOpen(openRows, sessionKey, item) ? EXPANDED_ROW_PX : COLLAPSED_ROW_PX;
158
+ }, [openRows, sessionKey]);
159
+ const virtual = useCodeTranscriptVirtualRows({ count: visible.length, resetKey: sessionKey, scrollElementRef: transcriptRef, getItemKey, estimateSize });
160
+ const { showJump, jumpToLatest } = useCodeTranscriptScroll({ items: visible, sessionKey, transcriptRef, virtual });
161
+ const setExpanded = useCallback((itemId: string, open: boolean) => {
162
+ const key = `${sessionKey}:${itemId}`;
163
+ setOpenRows(current => {
164
+ if (current.get(key) === open) return current;
165
+ const next = new Map(current);
166
+ next.delete(key); next.set(key, open);
167
+ // Same bound as the scroll anchors: remembering every row a reader
168
+ // ever touched is not worth an unbounded map. Re-inserting above
169
+ // keeps the row just acted on newest, so it is never the one evicted.
170
+ if (next.size > MAX_OPEN_ROWS) {
171
+ const oldest = next.keys().next().value;
172
+ if (oldest !== undefined) next.delete(oldest);
173
+ }
174
+ return next;
175
+ });
176
+ }, [sessionKey]);
70
177
  async function older() {
71
178
  if (historyGuard.current) return;
72
179
  historyGuard.current = true; setHistoryPending(true); setHistoryError(null);
@@ -92,14 +199,17 @@ export function CodeTranscript({ items, provider, sessionKey, workingDir, loadin
92
199
  {historyError?.sessionKey === sessionKey && <span className="code-action-error" role="alert">{historyError.message}</span>}
93
200
  </div>
94
201
  <div ref={transcriptRef} className="code-transcript" role="log" aria-label="Code transcript" aria-live="off" tabIndex={0} onKeyDown={keyboard}>
95
- {!items.length ? <div className="code-transcript-empty"><p>{loading ? 'Loading conversation…' : 'Type a prompt below to start this conversation.'}</p>
202
+ {!visible.length ? <div className="code-transcript-empty"><p>{loading ? 'Loading conversation…' : 'Type a prompt below to start this conversation.'}</p>
96
203
  <p className="code-transcript-cwd">Workspace: {workingDir || 'not set'}</p></div>
97
204
  : <div className="code-transcript-virtual-spacer" style={{ height: virtual.totalSize }}>
98
205
  {virtual.virtualItems.map(row => {
99
- const item = items[row.index];
206
+ const item = visible[row.index];
100
207
  return item ? <div key={row.key} ref={virtual.measureElement} className="code-transcript-virtual-row"
101
208
  data-code-transcript-idx={row.index} style={{ transform: `translateY(${row.start}px)` }}>
102
- <CodeTranscriptItem item={item} provider={provider} sessionKey={sessionKey} onOpenLocalFile={onOpenLocalFile} />
209
+ <CodeTranscriptItem item={item} provider={provider} sessionKey={sessionKey}
210
+ workingDir={workingDir} onOpenLocalFile={onOpenLocalFile}
211
+ expanded={isRowOpen(openRows, sessionKey, item)}
212
+ onExpandedChange={setExpanded} />
103
213
  </div> : null;
104
214
  })}
105
215
  </div>}
@@ -1,9 +1,11 @@
1
- import { useRef, useState } from 'react';
1
+ import { useCallback, useRef, useState } from 'react';
2
2
  import type { CodeControllerModel } from './code-controller-types';
3
3
  import { CodeComposer } from './CodeComposer';
4
4
  import { ComposerFooter } from './ComposerFooter';
5
5
  import { CodePermissionQueue } from './CodePermissionQueue';
6
6
  import { CodeTranscript } from './CodeTranscript';
7
+ import { CodeToastHost } from './CodeToastHost';
8
+ import { applyCodeNotice, dismissCodeToast, type CodeNotice, type CodeToast } from './code-toasts';
7
9
  import { CodeWorkspaceHeader } from './CodeWorkspaceHeader';
8
10
  import { codeCanResume } from './code-types';
9
11
 
@@ -13,6 +15,15 @@ export function CodeWorkbench({ controller: c, endpointKey, onOpenLocalFile }: P
13
15
  const [actionError, setActionError] = useState<{ key: string; message: string } | null>(null);
14
16
  const [retrying, setRetrying] = useState<string | null>(null);
15
17
  const retryGuard = useRef(new Set<string>());
18
+ const [toasts, setToasts] = useState<readonly CodeToast[]>([]);
19
+ // Notices are not cleared on a session change. The one notice that exists
20
+ // is derived from the current policy and re-raised by the footer on every
21
+ // mount, so wiping the stack here would delete it at the moment a draft
22
+ // becomes a running session -- exactly when it matters most.
23
+ const notify = useCallback((notice: CodeNotice) => {
24
+ setToasts(current => applyCodeNotice(current, notice));
25
+ }, []);
26
+ const dismiss = useCallback((id: string) => setToasts(current => dismissCodeToast(current, id)), []);
16
27
  const archived = c.session?.archivedAt != null;
17
28
  const stopping = c.session?.status === 'stopping' || c.operation.kind === 'stopping';
18
29
  const busy = c.busy || stopping || c.session?.status === 'starting' || c.session?.status === 'streaming';
@@ -69,6 +80,9 @@ export function CodeWorkbench({ controller: c, endpointKey, onOpenLocalFile }: P
69
80
  {canResume ? <button type="button" onClick={() => perform(c.resume)}>Resume session</button>
70
81
  : <><span>{c.session.resume.reason ?? 'Resume is not currently available.'}</span><button type="button" onClick={c.newSession}>New session</button></>}
71
82
  </section>}
83
+ {/* Directly above the transcript, so a notice sits over the conversation
84
+ rather than over the workspace header and its picker. */}
85
+ <CodeToastHost toasts={toasts} onDismiss={dismiss} />
72
86
  <CodeTranscript items={c.items} provider={c.session?.provider ?? c.selection.provider} sessionKey={sessionKey}
73
87
  workingDir={c.session?.cwd ?? c.selection.cwd} loading={c.loading} hasOlderHistory={c.hasOlderHistory}
74
88
  loadOlderHistory={c.loadOlderHistory} permissionCount={c.permissions.length} onOpenLocalFile={onOpenLocalFile} />
@@ -82,7 +96,7 @@ export function CodeWorkbench({ controller: c, endpointKey, onOpenLocalFile }: P
82
96
  <div className="code-composer-surface" aria-label="Code composer controls">
83
97
  <CodeComposer key={`composer:${sessionKey}`} inputText={c.input} canSend={canSend} busy={busy} canStop={canStop} stopping={stopping}
84
98
  pending={c.pending} readOnly={archived} onInputChange={c.setInput} onSubmit={c.send} onStop={c.stop} />
85
- <ComposerFooter key={`footer:${sessionKey}`} controller={c} />
99
+ <ComposerFooter key={`footer:${sessionKey}`} controller={c} onNotice={notify} />
86
100
  </div>
87
101
  </div>
88
102
  </div>;
@@ -1,48 +1,80 @@
1
- import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
1
+ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import type { CodeControllerModel } from './code-controller-types';
4
4
  import type { CodeCreateSessionRequest } from '../../../../src/code-mode/wire';
5
5
  import { CODE_POLICY_DETAILS, CODE_POLICY_LABELS, CODE_RUNTIME_LABELS } from './code-types';
6
+ import type { CodeNotice } from './code-toasts';
7
+ import { CheckGlyph, ProviderGlyph } from './ProviderGlyph';
8
+ import { ContextUsageMeter } from './ContextUsageMeter';
6
9
 
7
- type MenuOption<T extends string> = { value: T; label: string; detail?: string | undefined; disabled?: boolean };
8
- export function CodeFooterMenu<T extends string>({ label, value, options, disabled, onChange, displayValue }: {
9
- label: string; value: T; options: MenuOption<T>[]; disabled: boolean; onChange(value: T): void; displayValue?: string;
10
+ type MenuOption<T extends string> = {
11
+ value: T; label: string; detail?: string | undefined; disabled?: boolean; icon?: ReactNode;
12
+ };
13
+
14
+ /**
15
+ * One dropup for every composer control.
16
+ *
17
+ * `triggerContent` replaces the label+value pair for icon-only controls; the
18
+ * accessible name still comes from `label`, so an icon trigger is never
19
+ * anonymous. `filterable` adds a search field, which the model menu needs
20
+ * because a live Codex catalog can carry 28+ routed ids.
21
+ */
22
+ export function CodeFooterMenu<T extends string>({ label, value, options, disabled, onChange, displayValue,
23
+ triggerContent, filterable, filterPlaceholder, className, title }: {
24
+ label: string; value: T; options: MenuOption<T>[]; disabled: boolean; onChange(value: T): void;
25
+ displayValue?: string; triggerContent?: ReactNode; filterable?: boolean; filterPlaceholder?: string;
26
+ className?: string; title?: string;
10
27
  }) {
11
28
  const id = useId();
12
- const trigger = useRef<HTMLButtonElement>(null);
29
+ const triggerRef = useRef<HTMLButtonElement>(null);
13
30
  const menu = useRef<HTMLDivElement>(null);
31
+ const search = useRef<HTMLInputElement>(null);
14
32
  const [position, setPosition] = useState<{ left: number; bottom: number | 'auto'; top?: number; width: number; maxHeight: number } | null>(null);
33
+ const [query, setQuery] = useState('');
15
34
  const selected = options.find(option => option.value === value);
16
- function close(restore = true) { setPosition(null); if (restore) trigger.current?.focus(); }
35
+ const visible = useMemo(() => {
36
+ const needle = query.trim().toLowerCase();
37
+ if (!filterable || !needle) return options;
38
+ return options.filter(option => option.value.toLowerCase().includes(needle) || option.label.toLowerCase().includes(needle));
39
+ }, [filterable, options, query]);
40
+ function close(restore = true) { setPosition(null); setQuery(''); if (restore) triggerRef.current?.focus(); }
17
41
  function open() {
18
- const rect = trigger.current?.getBoundingClientRect();
42
+ const rect = triggerRef.current?.getBoundingClientRect();
19
43
  if (!rect || disabled) return;
20
- const width = Math.min(340, window.innerWidth - 28);
44
+ const width = Math.max(192, Math.min(filterable ? 340 : 260, window.innerWidth - 28));
21
45
  const above = Math.max(0, rect.top - 16);
22
46
  const below = Math.max(0, window.innerHeight - rect.bottom - 16);
23
47
  const common = { left: Math.max(14, Math.min(rect.left, window.innerWidth - width - 14)), width };
24
48
  setPosition(above >= below
25
- ? { ...common, bottom: window.innerHeight - rect.top + 8, maxHeight: Math.min(280, above) }
26
- : { ...common, bottom: 'auto', top: rect.bottom + 8, maxHeight: Math.min(280, below) });
49
+ ? { ...common, bottom: window.innerHeight - rect.top + 8, maxHeight: Math.min(288, above) }
50
+ : { ...common, bottom: 'auto', top: rect.bottom + 8, maxHeight: Math.min(288, below) });
27
51
  }
28
52
  useEffect(() => {
29
53
  if (!position) return;
30
- const buttons = menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)');
31
- const selectedButton = [...(buttons ?? [])].find(button => button.getAttribute('aria-selected') === 'true');
32
- (selectedButton ?? buttons?.[0])?.focus();
54
+ // A filterable menu types first; otherwise land on the current choice so
55
+ // arrow keys move relative to what is already selected.
56
+ if (filterable) search.current?.focus();
57
+ else {
58
+ const buttons = menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)');
59
+ const current = [...(buttons ?? [])].find(button => button.getAttribute('aria-selected') === 'true');
60
+ (current ?? buttons?.[0])?.focus();
61
+ }
33
62
  const outside = (event: PointerEvent) => {
34
- if (!menu.current?.contains(event.target as Node) && !trigger.current?.contains(event.target as Node)) close(false);
63
+ if (!menu.current?.contains(event.target as Node) && !triggerRef.current?.contains(event.target as Node)) close(false);
35
64
  };
36
65
  const resize = () => close(false);
37
66
  document.addEventListener('pointerdown', outside);
38
67
  window.addEventListener('resize', resize);
39
68
  return () => { document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', resize); };
40
- }, [position]);
41
- useEffect(() => { if (disabled) setPosition(null); }, [disabled]);
69
+ }, [position, filterable]);
70
+ useEffect(() => { if (disabled) { setPosition(null); setQuery(''); } }, [disabled]);
42
71
  function navigate(event: KeyboardEvent<HTMLDivElement>) {
43
72
  if (event.key === 'Escape') { event.preventDefault(); close(); return; }
44
- if (event.key === 'Tab') { event.preventDefault(); close(); return; }
73
+ // Tab closes the menu but keeps its own meaning: restoring focus to the
74
+ // trigger here would swallow the move and strand the user for one press.
75
+ if (event.key === 'Tab') { close(false); return; }
45
76
  const buttons = [...(menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])];
77
+ if (buttons.length === 0) return;
46
78
  const current = buttons.findIndex(button => button === document.activeElement);
47
79
  let index: number;
48
80
  if (event.key === 'ArrowDown') index = (current + 1) % buttons.length;
@@ -52,36 +84,69 @@ export function CodeFooterMenu<T extends string>({ label, value, options, disabl
52
84
  else return;
53
85
  event.preventDefault(); buttons[index]?.focus();
54
86
  }
55
- return <div className="code-footer-menu">
56
- <button ref={trigger} type="button" className="code-footer-menu-trigger" aria-label={label}
57
- aria-haspopup="listbox" aria-expanded={Boolean(position)} aria-controls={position ? id : undefined}
58
- disabled={disabled} title={selected?.detail} onClick={() => position ? close() : open()}
87
+ const shown = selected?.label ?? displayValue ?? (value || 'not selected');
88
+ const name = `${label}: ${shown}`;
89
+ return <div className={`code-footer-menu${className ? ` ${className}` : ''}`}>
90
+ <button ref={triggerRef} type="button" className={`code-footer-menu-trigger${triggerContent ? ' is-icon' : ''}`}
91
+ aria-label={name} aria-haspopup="listbox" aria-expanded={Boolean(position)} aria-controls={position ? id : undefined}
92
+ disabled={disabled} title={title ?? selected?.detail ?? name} onClick={() => position ? close() : open()}
59
93
  onKeyDown={event => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); open(); } }}>
60
- <span className="code-footer-label">{label}</span><strong>{selected?.label ?? displayValue ?? (value || 'Not selected')}</strong>
94
+ {triggerContent ?? <><span className="code-footer-label">{label}</span>
95
+ <strong>{selected?.label ?? displayValue ?? (value || 'Not selected')}</strong></>}
61
96
  </button>
62
97
  {position && createPortal(<div ref={menu} id={id} role="listbox" aria-label={label} className="code-footer-dropup code-footer-floating"
63
98
  style={position} onKeyDown={navigate}>
64
- {options.map(option => <button type="button" role="option" aria-selected={option.value === value}
65
- key={option.value} disabled={option.disabled} className="code-footer-dropup-option"
66
- onClick={() => { onChange(option.value); close(); }}>
67
- <span><strong>{option.label}</strong>{option.detail && <small>{option.detail}</small>}</span>
68
- </button>)}
99
+ {filterable && <input ref={search} type="text" className="code-footer-filter" value={query}
100
+ aria-label={`Filter ${label.toLowerCase()}`} placeholder={filterPlaceholder ?? 'Filter…'}
101
+ onChange={event => setQuery(event.target.value)} />}
102
+ <div className="code-footer-dropup-list">
103
+ {visible.map(option => <button type="button" role="option" aria-selected={option.value === value}
104
+ key={option.value} disabled={option.disabled} className="code-footer-dropup-option"
105
+ title={option.detail ?? option.label}
106
+ onClick={() => { onChange(option.value); close(); }}>
107
+ {option.icon && <span className="code-footer-option-icon">{option.icon}</span>}
108
+ <span className="code-footer-option-text"><strong>{option.label}</strong>{option.detail && <small>{option.detail}</small>}</span>
109
+ {option.value === value && <CheckGlyph />}
110
+ </button>)}
111
+ {visible.length === 0 && <p className="code-footer-dropup-empty" role="note">No match for “{query.trim()}”.</p>}
112
+ </div>
69
113
  </div>, document.body)}
70
114
  </div>;
71
115
  }
72
116
 
73
- export function ComposerFooter({ controller: c }: { controller: CodeControllerModel }) {
117
+ export function ComposerFooter({ controller: c, onNotice }: {
118
+ controller: CodeControllerModel;
119
+ onNotice?: ((notice: CodeNotice) => void) | undefined;
120
+ }) {
74
121
  const selection = c.selection;
75
122
  const provider = c.catalog?.providers.find(entry => entry.id === selection.provider);
76
123
  const capabilities = c.session?.capabilities ?? provider?.capabilities;
77
124
  const locked = c.selectedId !== null;
78
125
  const disabled = c.pending || c.busy || c.creationUnknown || c.operation.kind !== 'idle' || (locked && (!c.synced || c.session?.status !== 'idle' || c.session?.archivedAt !== null));
79
- const [modelText, setModelText] = useState(selection.model);
80
126
  const [saving, setSaving] = useState(false);
81
127
  const [error, setError] = useState<string | null>(null);
82
128
  const guard = useRef(false);
83
- const modelSuggestions = useId();
84
- useEffect(() => { setModelText(selection.model); }, [selection.model]);
129
+ const noticeRef = useRef(onNotice); noticeRef.current = onNotice;
130
+ // Announce the change, not the state. "Auto (YOLO)" as a permanent line
131
+ // stops being read after the first time; the moment it is switched on is
132
+ // the moment worth interrupting for. What the policy currently is stays
133
+ // legible on the Permission control itself.
134
+ useEffect(() => {
135
+ if (selection.permissionMode !== 'auto') {
136
+ // Leaving auto retires the warning immediately. A notice that
137
+ // outlived the policy would be stating something false about what
138
+ // the session is allowed to do.
139
+ noticeRef.current?.({ id: 'code:permission-mode', clear: true });
140
+ return;
141
+ }
142
+ // Derived from the current policy, not from a transition. The footer
143
+ // remounts when the draft becomes a real session, and a transition
144
+ // detector reads that remount as "no change" -- which deleted the
145
+ // warning at the exact moment the session started acting on it.
146
+ noticeRef.current?.({ id: 'code:permission-mode', variant: 'warning',
147
+ message: 'Auto (YOLO): actions may run without approval.',
148
+ durationMs: Number.POSITIVE_INFINITY });
149
+ }, [selection.permissionMode]);
85
150
  async function change(patch: Partial<CodeCreateSessionRequest>) {
86
151
  if (disabled || guard.current) return;
87
152
  guard.current = true; setSaving(true); setError(null);
@@ -90,40 +155,59 @@ export function ComposerFooter({ controller: c }: { controller: CodeControllerMo
90
155
  finally { guard.current = false; setSaving(false); }
91
156
  }
92
157
  const controlsDisabled = disabled || saving;
93
- const modelDisabled = controlsDisabled;
158
+ // A model may accept a narrower effort set than the provider union, so the
159
+ // per-model set is preferred. An open session is additionally bound by the
160
+ // capabilities it stored at creation -- the server checks both -- so offer
161
+ // the intersection rather than a value the session would reject.
162
+ const catalogEfforts = provider?.effortsByModel?.[selection.model] ?? capabilities?.efforts ?? [];
163
+ const sessionEfforts = c.session?.capabilities.efforts;
164
+ const modelEfforts = sessionEfforts
165
+ ? catalogEfforts.filter(value => sessionEfforts.includes(value))
166
+ : catalogEfforts;
167
+ const models = provider?.models ?? [];
94
168
  return <>
95
169
  <div className="code-composer-footer">
96
- <CodeFooterMenu label="Runtime" value={selection.provider} disabled={locked ? !c.catalog : controlsDisabled || !c.catalog}
170
+ <CodeFooterMenu label="Runtime" value={selection.provider} className="code-footer-runtime"
171
+ disabled={locked ? !c.catalog : controlsDisabled || !c.catalog}
172
+ triggerContent={<ProviderGlyph provider={selection.provider} />}
173
+ title={CODE_RUNTIME_LABELS[selection.provider]}
97
174
  options={(Object.keys(CODE_RUNTIME_LABELS) as Array<keyof typeof CODE_RUNTIME_LABELS>).map(id => {
98
175
  const entry = c.catalog?.providers.find(p => p.id === id);
99
176
  return { value: id, label: locked && id !== selection.provider ? `New ${CODE_RUNTIME_LABELS[id]} session` : CODE_RUNTIME_LABELS[id],
100
177
  disabled: !entry?.available || (locked && id === selection.provider),
178
+ icon: <ProviderGlyph provider={id} />,
101
179
  detail: entry?.reason ?? (entry?.available ? undefined : 'Availability unknown') };
102
180
  })} onChange={value => {
103
181
  if (locked) void c.setSelection({ provider: value }).catch(err => setError(err instanceof Error ? err.message : String(err)));
104
182
  else void change({ provider: value });
105
183
  }} />
106
- <form className="code-footer-model-form" onSubmit={event => { event.preventDefault(); if (modelText.trim() && !modelDisabled) void change({ model: modelText.trim() }); }}>
107
- <label className="code-footer-label" htmlFor={`${modelSuggestions}-input`}>Model</label>
108
- <input id={`${modelSuggestions}-input`} aria-label="Native model ID" list={modelSuggestions} value={modelText}
109
- disabled={modelDisabled} onChange={event => setModelText(event.target.value)}
110
- onKeyDown={event => { if (event.key === 'Escape') { setModelText(selection.model); event.currentTarget.blur(); } }} />
111
- <datalist id={modelSuggestions}>{provider?.models.map(model => <option key={model} value={model} />)}</datalist>
112
- {modelText !== selection.model && <button type="submit" disabled={modelDisabled || !modelText.trim()}>Apply</button>}
113
- </form>
114
- {!!capabilities?.efforts.length && <CodeFooterMenu label="Effort" value={selection.effort ?? ''}
115
- options={[{ value: '', label: 'Native default' }, ...capabilities.efforts.map(value => ({ value, label: value }))]}
116
- disabled={controlsDisabled} onChange={value => void change({ effort: value || null })} />}
117
- <CodeFooterMenu label="Permission" value={selection.permissionMode} displayValue={CODE_POLICY_LABELS[selection.permissionMode]} disabled={controlsDisabled || !capabilities?.permissions}
184
+ <CodeFooterMenu label="Permission" value={selection.permissionMode} displayValue={CODE_POLICY_LABELS[selection.permissionMode]}
185
+ disabled={controlsDisabled || !capabilities?.permissions}
118
186
  options={(capabilities?.permissionModes ?? []).map(value => ({ value, label: CODE_POLICY_LABELS[value], detail: CODE_POLICY_DETAILS[value] }))}
119
187
  onChange={value => void change({ permissionMode: value })} />
188
+ <span className="code-composer-footer-spacer" aria-hidden="true" />
189
+ <ContextUsageMeter usage={c.session?.contextUsage} />
190
+ <CodeFooterMenu label="Model" value={selection.model} className="code-footer-model"
191
+ disabled={controlsDisabled || models.length === 0} filterable filterPlaceholder="Filter models…"
192
+ displayValue={selection.model || 'Select model'}
193
+ options={models.map(value => ({ value, label: value }))}
194
+ onChange={value => void change({ model: value })} />
195
+ {modelEfforts.length > 0 && <CodeFooterMenu label="Effort" value={selection.effort ?? ''}
196
+ className="code-footer-effort" displayValue={selection.effort ?? 'Default'}
197
+ options={[{ value: '', label: 'Native default' }, ...modelEfforts.map(value => ({ value, label: value }))]}
198
+ disabled={controlsDisabled} onChange={value => void change({ effort: value || null })} />}
120
199
  {locked && <button type="button" className="code-inline-action" onClick={c.newSession}>New session</button>}
121
200
  </div>
201
+ {models.length === 0 && provider?.available && <div className="code-selection-notice">This runtime published no models.
202
+ <button type="button" className="code-inline-action" onClick={() => { void c.refresh().catch(err => setError(err instanceof Error ? err.message : String(err))); }}>Refresh catalog</button>
203
+ </div>}
122
204
  {!provider?.available && <div className="code-selection-notice">{provider?.reason ?? 'Runtime availability has not been confirmed.'}
123
205
  <button type="button" className="code-inline-action" onClick={() => { void c.refresh().catch(err => setError(err instanceof Error ? err.message : String(err))); }}>Refresh availability</button>
124
206
  </div>}
125
- {selection.permissionMode === 'auto' && <p className="code-policy-note">Auto (YOLO): actions may run without approval.</p>}
126
207
  {(saving || c.operation.kind === 'patching') && <span role="status">Saving settings…</span>}
208
+ {/* Stays inline. A failed setting change is something the reader has to
209
+ act on -- the control reverted and this is the only account of why --
210
+ and it must not leave on a timer before they have read it. */}
127
211
  {error && <div className="code-action-error" role="alert">{error}</div>}
128
212
  </>;
129
213
  }
@@ -0,0 +1,72 @@
1
+ import { useId, useState } from 'react';
2
+ import type { CodeContextUsage } from '../../../../src/code-mode/wire';
3
+ import { contextUsageLabel, contextUsageLevel, contextUsageView, formatExactTokens, formatOptionalTokens, formatTokens } from './context-usage';
4
+
5
+ /**
6
+ * How full the context window is, as a ring beside the composer controls.
7
+ *
8
+ * It renders nothing when the runtime has not reported usage, rather than
9
+ * showing a zero that would read as measured. When a count arrives without a
10
+ * window there is no ring, because a proportion of an unknown total is not a
11
+ * proportion.
12
+ */
13
+ export function ContextUsageMeter({ usage }: { usage: CodeContextUsage | undefined }) {
14
+ const view = contextUsageView(usage);
15
+ // Hover and focus are separate reasons to show the detail, tracked
16
+ // separately. One boolean made them fight: clicking closed a panel the
17
+ // pointer had opened, and blurring closed one the pointer was still over.
18
+ const [hovered, setHovered] = useState(false);
19
+ const [focused, setFocused] = useState(false);
20
+ const open = hovered || focused;
21
+ const detailId = useId();
22
+ if (view.state === 'hidden') return null;
23
+ const label = contextUsageLabel(view);
24
+ const level = view.state === 'measured' ? (view.over ? 'critical' : contextUsageLevel(view.percent)) : 'normal';
25
+ const circumference = 2 * Math.PI * 7;
26
+ const filled = view.state === 'measured' ? (view.percent / 100) * circumference : 0;
27
+ return <div className="code-context-usage"
28
+ onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)}>
29
+ {/* Described by, not expanded: the panel is a role="note" description
30
+ and its numbers are already in this button's accessible name, so
31
+ announcing an expandable widget would offer a control that reveals
32
+ nothing new. */}
33
+ <button type="button" className={`code-context-trigger is-${level}`}
34
+ aria-label={label} aria-describedby={open ? detailId : undefined}
35
+ onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}>
36
+ {view.state === 'measured'
37
+ ? <svg className="code-context-ring" viewBox="0 0 18 18" aria-hidden="true">
38
+ <circle className="code-context-ring-track" cx="9" cy="9" r="7" />
39
+ <circle className="code-context-ring-fill" cx="9" cy="9" r="7"
40
+ strokeDasharray={`${filled} ${circumference}`} />
41
+ </svg>
42
+ : null}
43
+ <span className="code-context-value">
44
+ {view.state !== 'measured' ? formatTokens(view.usedTokens) : view.over ? 'over' : `${view.percent}%`}
45
+ </span>
46
+ </button>
47
+ {open && <div id={detailId} className="code-context-detail" role="note">
48
+ <p className="code-context-detail-total">
49
+ {view.state === 'measured'
50
+ ? `${formatTokens(view.usedTokens)} of ${formatTokens(view.maxTokens)} tokens`
51
+ : `${formatTokens(view.usedTokens)} tokens used`}
52
+ </p>
53
+ {view.state === 'measured' && view.over
54
+ && <p className="code-context-detail-note">Past the window the runtime last reported.</p>}
55
+ {view.state === 'counted' && <p className="code-context-detail-note">This runtime did not report a context window.</p>}
56
+ <dl className="code-context-breakdown">
57
+ {/* Exact here, because the rounded form on the trigger cannot be
58
+ reconstructed into it and this is the only place the real
59
+ number appears. */}
60
+ <div><dt>Used</dt><dd>{formatExactTokens(usage?.totalTokens ?? null)}</dd></div>
61
+ <div><dt>Input</dt><dd>{formatOptionalTokens(usage?.inputTokens ?? null)}</dd></div>
62
+ <div><dt>Cached</dt><dd>{formatOptionalTokens(usage?.cachedInputTokens ?? null)}</dd></div>
63
+ <div><dt>Output</dt><dd>{formatOptionalTokens(usage?.outputTokens ?? null)}</dd></div>
64
+ <div><dt>Reasoning</dt><dd>{formatOptionalTokens(usage?.reasoningOutputTokens ?? null)}</dd></div>
65
+ </dl>
66
+ {/* Spend, not occupancy: it counts every turn's tokens including the
67
+ prompt resent each time, so it passes the window in ordinary use. */}
68
+ {usage?.processedTokens != null && <p className="code-context-detail-note">
69
+ Processed across the conversation: {formatTokens(usage.processedTokens)}</p>}
70
+ </div>}
71
+ </div>;
72
+ }