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.
- package/README.md +3 -3
- package/dist/bin/commands/browser.js +52 -5
- package/dist/bin/commands/browser.js.map +1 -1
- package/dist/src/agent/args.js +69 -19
- package/dist/src/agent/args.js.map +1 -1
- package/dist/src/agent/codex-app-events.js +31 -1
- package/dist/src/agent/codex-app-events.js.map +1 -1
- package/dist/src/agent/cursor-model-inventory.js +140 -0
- package/dist/src/agent/cursor-model-inventory.js.map +1 -0
- package/dist/src/agent/grok-models.js +80 -0
- package/dist/src/agent/grok-models.js.map +1 -0
- package/dist/src/browser/actions.js +383 -20
- package/dist/src/browser/actions.js.map +1 -1
- package/dist/src/browser/grounding-candidate.js +272 -0
- package/dist/src/browser/grounding-candidate.js.map +1 -0
- package/dist/src/browser/grounding-eval.js +247 -0
- package/dist/src/browser/grounding-eval.js.map +1 -0
- package/dist/src/browser/image-size.js +63 -0
- package/dist/src/browser/image-size.js.map +1 -0
- package/dist/src/browser/index.js +1 -0
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/browser/occlusion.js +148 -0
- package/dist/src/browser/occlusion.js.map +1 -0
- package/dist/src/browser/verify-candidate.js +162 -0
- package/dist/src/browser/verify-candidate.js.map +1 -0
- package/dist/src/browser/vision-input.js +74 -0
- package/dist/src/browser/vision-input.js.map +1 -0
- package/dist/src/browser/vision-provider.js +75 -0
- package/dist/src/browser/vision-provider.js.map +1 -0
- package/dist/src/browser/vision.js +360 -47
- package/dist/src/browser/vision.js.map +1 -1
- package/dist/src/browser/web-ai/candidate-reconcile.js +14 -5
- package/dist/src/browser/web-ai/candidate-reconcile.js.map +1 -1
- package/dist/src/cli/claude-model-discovery.js +175 -0
- package/dist/src/cli/claude-model-discovery.js.map +1 -0
- package/dist/src/cli/claude-models.js +46 -0
- package/dist/src/cli/claude-models.js.map +1 -1
- package/dist/src/cli/registry-live.js +104 -1
- package/dist/src/cli/registry-live.js.map +1 -1
- package/dist/src/cli/registry.js +28 -5
- package/dist/src/cli/registry.js.map +1 -1
- package/dist/src/code-mode/host.js +6 -0
- package/dist/src/code-mode/host.js.map +1 -1
- package/dist/src/code-mode/manager.js +37 -8
- package/dist/src/code-mode/manager.js.map +1 -1
- package/dist/src/code-mode/providers/catalog.js +53 -1
- package/dist/src/code-mode/providers/catalog.js.map +1 -1
- package/dist/src/code-mode/providers/codex-app.js +6 -0
- package/dist/src/code-mode/providers/codex-app.js.map +1 -1
- package/dist/src/code-mode/providers/live-models.js +85 -0
- package/dist/src/code-mode/providers/live-models.js.map +1 -0
- package/dist/src/code-mode/providers/provider-live-models.js +120 -0
- package/dist/src/code-mode/providers/provider-live-models.js.map +1 -0
- package/dist/src/code-mode/session.js +37 -0
- package/dist/src/code-mode/session.js.map +1 -1
- package/dist/src/prompt/templates/a1-system.md +24 -14
- package/dist/src/prompt/templates/control-system.md +6 -11
- package/dist/src/prompt/templates/employee.md +5 -5
- package/dist/src/routes/browser.js +17 -5
- package/dist/src/routes/browser.js.map +1 -1
- package/package.json +1 -1
- package/public/css/activity.css +20 -1
- package/public/css/layout.css +7 -3
- package/public/dist/.vite/manifest.json +104 -105
- package/public/dist/assets/Agent-CQ4EBgY8.js +1 -0
- package/public/dist/assets/CodeCanvas-DWsx6sv5.js +2 -0
- package/public/dist/assets/DashboardMeta-Dh-tasbY.js +1 -0
- package/public/dist/assets/Display-NrqY_IaN.js +1 -0
- package/public/dist/assets/{Permissions-BzbnqenZ.js → Permissions-Bh947tUG.js} +1 -1
- package/public/dist/assets/Permissions-DzNJCt8K.js +1 -0
- package/public/dist/assets/app-BCBnxiGr.css +1 -0
- package/public/dist/assets/{app-x5Kray4W.js → app-Q0prKOFx.js} +4 -4
- package/public/dist/assets/{bgtask-badge-W9djRQVu.js → bgtask-badge-DAcWaUfS.js} +1 -1
- package/public/dist/assets/bgtask-badge-DHA2OQm8.js +1 -0
- package/public/dist/assets/{employees--_dmDw_a.js → employees-CWS3Juca.js} +1 -1
- package/public/dist/assets/{iframe-renderer-D4BmLtAq.js → iframe-renderer-DCDKBMb7.js} +2 -2
- package/public/dist/assets/iframe-renderer-RpvBly2x.js +1 -0
- package/public/dist/assets/manager-Nk7-RYPs.css +1 -0
- package/public/dist/assets/manager-XkR-DRaQ.js +5 -0
- package/public/dist/assets/manager-shortcuts-jsiX-zV6.js +1 -0
- package/public/dist/assets/memory-4YH8sz0L.js +1 -0
- package/public/dist/assets/{memory-DENQWzM8.js → memory-DhNdKZLO.js} +1 -1
- package/public/dist/assets/{message-history-BsrLcGJj.js → message-history-DbRTl1P0.js} +1 -1
- package/public/dist/assets/message-history-lVcLpAw8.js +1 -0
- package/public/dist/assets/{render-CF6Q1oIh.js → render-DP8SJtdZ.js} +1 -1
- package/public/dist/assets/settings--EGy3_B9.js +1 -0
- package/public/dist/assets/settings-85n9NjvX.js +1 -0
- package/public/dist/assets/{settings-B-C_jdZR.js → settings-C9_D-pn0.js} +32 -32
- package/public/dist/assets/settings-core-8wBl1mg-.js +1 -0
- package/public/dist/assets/{settings-core-BzAJLsKN.js → settings-core-BcVD2FgV.js} +1 -1
- package/public/dist/assets/{settings-embedding-dEMyByVi.js → settings-embedding-BSpq785l.js} +1 -1
- package/public/dist/assets/{settings-embedding-BSA42oKM.css → settings-embedding-LIMq5ebM.css} +1 -1
- package/public/dist/assets/{sidebar-fFEOpvJl.js → sidebar-BVhLb5Ft.js} +3 -3
- package/public/dist/assets/skills-6GFkHTlQ.js +1 -0
- package/public/dist/assets/{skills-Da_b06ci.js → skills-X7qxmHka.js} +1 -1
- package/public/dist/assets/{slash-commands-BbPUHHF4.js → slash-commands-BnDH4IU_.js} +1 -1
- package/public/dist/assets/slash-commands-qu8fPAZ3.js +1 -0
- package/public/dist/assets/{trace-drawer-B-QmzUfP.js → trace-drawer-DasaS1z4.js} +1 -1
- package/public/dist/assets/{ui-CkRUrIfp.js → ui-BtiBTXng.js} +1 -1
- package/public/dist/assets/ui-BucJ1zMA.js +1 -0
- package/public/dist/index.html +2 -2
- package/public/dist/manager/index.html +3 -3
- package/public/dist/settings/index.html +2 -2
- package/public/js/features/settings.ts +16 -0
- package/public/manager/src/App.tsx +10 -12
- package/public/manager/src/AppChrome.tsx +2 -3
- package/public/manager/src/InstancePreview.tsx +47 -0
- package/public/manager/src/SidebarRailRouter.tsx +34 -30
- package/public/manager/src/code/CodeComposer.tsx +31 -6
- package/public/manager/src/code/CodeSessionList.tsx +25 -7
- package/public/manager/src/code/CodeToastHost.tsx +49 -0
- package/public/manager/src/code/CodeTranscript.tsx +136 -26
- package/public/manager/src/code/CodeWorkbench.tsx +16 -2
- package/public/manager/src/code/ComposerFooter.tsx +130 -46
- package/public/manager/src/code/ContextUsageMeter.tsx +72 -0
- package/public/manager/src/code/ProviderGlyph.tsx +58 -0
- package/public/manager/src/code/code-composer.css +159 -175
- package/public/manager/src/code/code-controller-runtime.ts +3 -1
- package/public/manager/src/code/code-session-state.ts +7 -1
- package/public/manager/src/code/code-toasts.ts +73 -0
- package/public/manager/src/code/code-workbench.css +42 -1
- package/public/manager/src/code/code.css +130 -15
- package/public/manager/src/code/context-usage.ts +74 -0
- package/public/manager/src/code/pending-user-item.ts +46 -0
- package/public/manager/src/code/session-order.ts +70 -0
- package/public/manager/src/code/tool-summary.ts +101 -0
- package/public/manager/src/code/use-dictation.ts +130 -0
- package/public/manager/src/components/CommandCenter.tsx +4 -8
- package/public/manager/src/components/InstanceDetailPanel.tsx +0 -18
- package/public/manager/src/components/Workbench.tsx +6 -10
- package/public/manager/src/components/WorkbenchHeader.tsx +0 -11
- package/public/manager/src/dashboard-settings/dashboard-settings-ui.ts +3 -1
- package/public/manager/src/hooks/useDashboardView.ts +38 -38
- package/public/manager/src/manager-components.css +0 -8
- package/public/manager/src/manager-shortcut-runner.ts +0 -1
- package/public/manager/src/manager-shortcuts.ts +0 -2
- package/public/manager/src/settings/pages/Agent.tsx +5 -2
- package/public/manager/src/settings/pages/DashboardMeta.tsx +20 -1
- package/public/manager/src/settings/pages/Permissions.tsx +23 -4
- package/public/manager/src/settings/pages/components/agent/PermissionQuickSection.tsx +6 -2
- package/public/manager/src/settings/pages/components/agent/agent-save.ts +24 -1
- package/public/manager/src/settings/settings-icons.ts +3 -1
- package/public/manager/src/settings/settings-registry.ts +3 -3
- package/public/manager/src/settings-controls.css +0 -1
- package/public/manager/src/settings-standalone.tsx +8 -8
- package/public/manager/src/types.ts +0 -1
- package/scripts/check-private-boundary.mjs +101 -0
- package/scripts/grounding-eval.mjs +223 -0
- package/public/dist/assets/Agent-B8ZEWJ6B.js +0 -1
- package/public/dist/assets/CodeCanvas-Bj3lTSu6.js +0 -2
- package/public/dist/assets/DashboardMeta-CmZavI4H.js +0 -1
- package/public/dist/assets/Display-DFkjKcMk.js +0 -1
- package/public/dist/assets/Permissions-CJftCO6p.js +0 -1
- package/public/dist/assets/app-DVbrIl8f.css +0 -1
- package/public/dist/assets/bgtask-badge-CuAc8bAl.js +0 -1
- package/public/dist/assets/iframe-renderer-By2WO5bd.js +0 -1
- package/public/dist/assets/manager-CxLXWCGi.css +0 -1
- package/public/dist/assets/manager-DA3dF3eN.js +0 -5
- package/public/dist/assets/manager-shortcuts-CzGkE0ZG.js +0 -1
- package/public/dist/assets/memory-thjr2apY.js +0 -1
- package/public/dist/assets/message-history-CfU7_P5t.js +0 -1
- package/public/dist/assets/settings-DXA5L4DR.js +0 -1
- package/public/dist/assets/settings-Dy_IaNVH.js +0 -1
- package/public/dist/assets/settings-core-D7sAQqhC.js +0 -1
- package/public/dist/assets/skills-BcIievZp.js +0 -1
- package/public/dist/assets/slash-commands-B-5Shayl.js +0 -1
- 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;
|
|
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=
|
|
33
|
-
<span className="code-tool-status">{
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
42
|
-
{
|
|
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
|
|
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 =
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
{!
|
|
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 =
|
|
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}
|
|
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> = {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
26
|
-
: { ...common, bottom: 'auto', top: rect.bottom + 8, maxHeight: Math.min(
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
(
|
|
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) && !
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 }: {
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
<
|
|
107
|
-
|
|
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
|
+
}
|