@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.100
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/CHANGELOG.md +548 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +736 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +8525 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +8575 -0
- package/dist/node/index.d.cts +736 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +736 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +8525 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +130 -0
- package/package.json +99 -3
- package/src/app-view/bridge.ts +217 -0
- package/src/app-view/call-tool-result.ts +58 -0
- package/src/app-view/host-context.ts +37 -0
- package/src/app-view/types.ts +12 -0
- package/src/client/runtime-host.ts +84 -0
- package/src/client/ws-session-client.ts +131 -0
- package/src/clipboard.ts +81 -0
- package/src/components/AgentActivityPanel.tsx +371 -0
- package/src/components/AgentSwitcherSheet.tsx +93 -0
- package/src/components/AppView.tsx +147 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +789 -0
- package/src/components/Dialog.tsx +230 -0
- package/src/components/DiffLines.tsx +81 -0
- package/src/components/ExecutionDetailSheet.tsx +142 -0
- package/src/components/HelpSheet.tsx +104 -0
- package/src/components/PermissionPrompt.tsx +762 -0
- package/src/components/PersonalUsageDashboard.tsx +39 -0
- package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
- package/src/components/PersonalUsageDashboardSections.tsx +250 -0
- package/src/components/PopupMenu.tsx +166 -0
- package/src/components/ProjectPanel.tsx +268 -0
- package/src/components/SessionMonitor.tsx +180 -0
- package/src/components/SessionSidebar.tsx +429 -0
- package/src/components/SessionSurface.tsx +482 -0
- package/src/components/SessionSurfaceChrome.tsx +319 -0
- package/src/components/SettingsFields.tsx +159 -0
- package/src/components/SettingsGeneralSection.tsx +49 -0
- package/src/components/SettingsMcpSection.tsx +157 -0
- package/src/components/SettingsPermissionsSection.tsx +137 -0
- package/src/components/SettingsPluginsSection.tsx +165 -0
- package/src/components/SettingsProvidersSection.tsx +211 -0
- package/src/components/SettingsScreen.tsx +340 -0
- package/src/components/StatusControls.tsx +294 -0
- package/src/components/composer-attachments.ts +246 -0
- package/src/components/personal-usage-dashboard-types.ts +26 -0
- package/src/driver-labels.ts +88 -0
- package/src/execution-entry-status.ts +55 -0
- package/src/goal-view.ts +19 -0
- package/src/hooks/command-menu.ts +65 -0
- package/src/hooks/excluded-commands.ts +52 -0
- package/src/hooks/prompt-state.ts +83 -0
- package/src/hooks/server-message-handling.ts +95 -0
- package/src/hooks/session-client-types.ts +344 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-app-view-requests.ts +178 -0
- package/src/hooks/use-execution-detail.ts +118 -0
- package/src/hooks/use-media-query.ts +27 -0
- package/src/hooks/use-model-list.ts +45 -0
- package/src/hooks/use-personal-usage.ts +138 -0
- package/src/hooks/use-project-panel.ts +117 -0
- package/src/hooks/use-schedules-state.ts +92 -0
- package/src/hooks/use-session-directory.ts +202 -0
- package/src/hooks/use-settings-state.ts +115 -0
- package/src/hooks/useSessionClient.ts +1051 -0
- package/src/index.ts +72 -0
- package/src/product-identity.tsx +26 -0
- package/src/schedule-view.ts +92 -0
- package/src/styles/surface.css +231 -0
- package/src/styles/surface.css.d.mts +1 -0
- package/src/styles/theme.css +38 -0
- package/src/styles/theme.css.d.mts +1 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { MessageCircleQuestion, ShieldAlert, Square, SquareCheck } from 'lucide-react';
|
|
4
|
+
import React, {
|
|
5
|
+
forwardRef,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
12
|
+
|
|
13
|
+
import { driverAttributionText, isSameSurface } from '../driver-labels.js';
|
|
14
|
+
import { ConfirmDialog } from './Dialog.js';
|
|
15
|
+
import { DiffLines } from './DiffLines.js';
|
|
16
|
+
|
|
17
|
+
import type { TPendingPrompt } from '../hooks/prompt-state.js';
|
|
18
|
+
import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
|
|
19
|
+
import type { TDriverId } from '@robota-sdk/agent-interface-session';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Renders the owner's pending permission/ask prompts (REMOTE-007/009). Under local == remote the paired
|
|
23
|
+
* browser owner answers its OWN prompts; the first pending prompt is shown. A gated tool call blocks
|
|
24
|
+
* until answered, so this is on the critical path — not decorative.
|
|
25
|
+
*
|
|
26
|
+
* `modal` covers the page; `dock` sits above the composer, as desktop agent apps place a pending
|
|
27
|
+
* question next to where the answer is typed. Once armed and focused, its keys answer it: 1–9 picks
|
|
28
|
+
* an option, Esc cancels a question or denies a permission — but it never takes that focus away from
|
|
29
|
+
* a field the person is typing in (#3280 §1). A click on a button always answers at once. An ask whose
|
|
30
|
+
* request sets `allowFreeText` also shows a text field (masked when `masked` is set); Enter there
|
|
31
|
+
* submits it, and its own keys never reach the prompt's shortcuts (#3280 §3). The field's typed value
|
|
32
|
+
* lives only in `FreeTextField`, keyed by `prompt.id`: a cancelled or answered prompt's text can
|
|
33
|
+
* never reach the next prompt's render, masked or not, because the field is a fresh component
|
|
34
|
+
* instance rather than a value carried over in this component's own state (#3280 §3 follow-up).
|
|
35
|
+
*
|
|
36
|
+
* An ask whose `maxSelect` is above 1 is a multi-select: each option is a toggle (a click or 1–9 ticks
|
|
37
|
+
* it, nothing answers yet), and Submit — or Enter at the prompt, once the selection is allowed —
|
|
38
|
+
* answers with the ticked values in option order. Unlike the free-text field, the ticks live in this
|
|
39
|
+
* component, because the dock's keys act on them; they are tagged with `prompt.id`, so a prompt never
|
|
40
|
+
* reads the ticks of another.
|
|
41
|
+
*/
|
|
42
|
+
interface IPermissionPromptProps {
|
|
43
|
+
prompts: readonly TPendingPrompt[];
|
|
44
|
+
onAnswerPermission: (id: string, result: boolean) => void;
|
|
45
|
+
onAnswerAsk: (id: string, response: TActionResponse) => void;
|
|
46
|
+
/** This connection's own driver id (§3289 §3): a prompt raised by its own turn shows no requester. */
|
|
47
|
+
ownDriverId?: TDriverId | null;
|
|
48
|
+
layout?: 'modal' | 'dock';
|
|
49
|
+
/** How long a new prompt waits before its keys answer it; tests pass their own. */
|
|
50
|
+
armDelayMs?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Called once an answer (click or key) leaves no prompt behind, if focus was inside this prompt at
|
|
53
|
+
* the time — so the caller can send it back to the field the person was typing in (the composer,
|
|
54
|
+
* for the `dock` layout). Not called when another prompt immediately follows: focus stays on the
|
|
55
|
+
* prompt for that one instead.
|
|
56
|
+
*/
|
|
57
|
+
onFocusReturn?: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A prompt can appear while the user is typing in a field. For this long after it appears the prompt
|
|
62
|
+
* leaves focus where it is, so the keystrokes land there instead of answering (`1` allows). A mouse
|
|
63
|
+
* click on a button still answers at once, and Esc still denies or cancels, because both are
|
|
64
|
+
* deliberate. A click made with the keyboard (Enter or Space on a focused button) waits for the
|
|
65
|
+
* prompt to arm like any other key.
|
|
66
|
+
*/
|
|
67
|
+
export const PROMPT_ARM_DELAY_MS = 450;
|
|
68
|
+
|
|
69
|
+
/** An element whose keystrokes the person relies on — a prompt must never take focus from one of these. */
|
|
70
|
+
function isEditableElement(element: Element | null): boolean {
|
|
71
|
+
return element !== null && element.matches('input, textarea, select, [contenteditable]');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* #3282 §2 (part 2) — the one destructive option in the provider profile action menu (the ask built
|
|
76
|
+
* by `askProviderProfileAction` in `agent-command`'s `provider-command-profile.ts`, `request.id`
|
|
77
|
+
* `'provider-profile-action'`): "Delete" answers to no digit and renders apart from Switch/Edit/Test/
|
|
78
|
+
* Duplicate, styled destructive — the exact audited bug ("Delete looks the same as Switch, answers to
|
|
79
|
+
* the key 5"). Every other ask (this `request.id` check is the only thing that scopes the change) is
|
|
80
|
+
* unaffected.
|
|
81
|
+
*/
|
|
82
|
+
function isDestructiveAskOption(requestId: string | undefined, value: string): boolean {
|
|
83
|
+
return requestId === 'provider-profile-action' && value === 'delete';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type TAskRequest = Extract<TPendingPrompt, { kind: 'ask' }>['request'];
|
|
87
|
+
type TAskOption = NonNullable<TAskRequest['options']>[number];
|
|
88
|
+
|
|
89
|
+
/** What a multi-select ask (`maxSelect` above 1) lets the person tick; see `readMultiSelect`. */
|
|
90
|
+
interface IMultiSelect {
|
|
91
|
+
min: number;
|
|
92
|
+
max: number;
|
|
93
|
+
/** The options that can be ticked, in the order the answer lists them. */
|
|
94
|
+
options: readonly TAskOption[];
|
|
95
|
+
/** The ticks a prompt starts with. */
|
|
96
|
+
defaults: ReadonlySet<string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The multi-select shape of an ask, or undefined for every other ask (which answers on click). The
|
|
101
|
+
* rules mirror the terminal checklist (`MultiSelectList` in agent-ui-terminal): `minSelect` (default 1)
|
|
102
|
+
* gates the submit, and `maxSelect` caps the ticks — a toggle beyond the cap is ignored, never swapped
|
|
103
|
+
* for the oldest tick. `default.values` pre-select; a default that names no option is dropped, and
|
|
104
|
+
* more defaults than the cap keep the first ones in option order, so the answer never breaks the cap.
|
|
105
|
+
*/
|
|
106
|
+
function readMultiSelect(request: TAskRequest): IMultiSelect | undefined {
|
|
107
|
+
const max = request.maxSelect ?? 1;
|
|
108
|
+
if (max <= 1) return undefined;
|
|
109
|
+
const options = (request.options ?? []).filter(
|
|
110
|
+
(opt) => !isDestructiveAskOption(request.id, opt.value),
|
|
111
|
+
);
|
|
112
|
+
const wanted = new Set(request.default?.values ?? []);
|
|
113
|
+
const defaults = options.filter((opt) => wanted.has(opt.value)).slice(0, max);
|
|
114
|
+
return {
|
|
115
|
+
min: request.minSelect ?? 1,
|
|
116
|
+
max,
|
|
117
|
+
options,
|
|
118
|
+
defaults: new Set(defaults.map((opt) => opt.value)),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function PermissionPrompt({
|
|
123
|
+
prompts,
|
|
124
|
+
onAnswerPermission,
|
|
125
|
+
onAnswerAsk,
|
|
126
|
+
ownDriverId = null,
|
|
127
|
+
layout = 'modal',
|
|
128
|
+
armDelayMs = PROMPT_ARM_DELAY_MS,
|
|
129
|
+
onFocusReturn,
|
|
130
|
+
}: IPermissionPromptProps): React.ReactElement | null {
|
|
131
|
+
const prompt = prompts[0];
|
|
132
|
+
const promptId = prompt?.id;
|
|
133
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
134
|
+
const fieldRef = useRef<HTMLInputElement>(null);
|
|
135
|
+
// A question with a text field (#3280 §3): the field is the prompt's primary control, so focus goes
|
|
136
|
+
// there instead of the container wherever this component would otherwise focus the container itself.
|
|
137
|
+
const hasFreeText = prompt?.kind === 'ask' && prompt.request.allowFreeText === true;
|
|
138
|
+
const [armedId, setArmedId] = useState<string | undefined>(undefined);
|
|
139
|
+
const [hasFocus, setHasFocus] = useState(false);
|
|
140
|
+
// Whether the field itself (not just some part of the prompt) currently holds focus — digits typed
|
|
141
|
+
// there type into the field rather than choosing a numbered option, so the hint must say so. Lifted
|
|
142
|
+
// from `FreeTextField` via `onFocusChange`, since the hint text is rendered up here.
|
|
143
|
+
const [fieldFocused, setFieldFocused] = useState(false);
|
|
144
|
+
// Imperative escape hatch onto the field's own state (see `FreeTextField`): every path that ends
|
|
145
|
+
// this prompt clears it explicitly, rather than relying on the field ever being asked to render a
|
|
146
|
+
// value that belongs to a prompt other than the one it was mounted for.
|
|
147
|
+
const fieldHandleRef = useRef<IFreeTextFieldHandle>(null);
|
|
148
|
+
// Set at the moment of an answer if focus was inside the prompt then; consumed once the prompt list
|
|
149
|
+
// empties (see below) or cleared once a next prompt shows it was not needed after all.
|
|
150
|
+
const pendingFocusReturnRef = useRef(false);
|
|
151
|
+
// A multi-select ask's ticks, tagged with the prompt they belong to: another prompt (a different
|
|
152
|
+
// `prompt.id`) reads its own defaults instead, so there is no stale selection to reset.
|
|
153
|
+
const [selection, setSelection] = useState<
|
|
154
|
+
{ promptId: string; values: ReadonlySet<string> } | undefined
|
|
155
|
+
>(undefined);
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (promptId === undefined || armDelayMs <= 0) return undefined;
|
|
158
|
+
const timer = setTimeout(() => setArmedId(promptId), armDelayMs);
|
|
159
|
+
return () => clearTimeout(timer);
|
|
160
|
+
}, [promptId, armDelayMs]);
|
|
161
|
+
const armed = promptId !== undefined && (armDelayMs <= 0 || armedId === promptId);
|
|
162
|
+
// Answering one prompt renders the next into the same buttons, so a button focused to answer the
|
|
163
|
+
// last one would take Enter, Space or a held key as an answer to this one. Focus goes back to the
|
|
164
|
+
// prompt itself, whose keys wait for the prompt to arm. A reused button is still there to find this
|
|
165
|
+
// way — but the free-text field is its own component, remounted (not updated) for the new prompt.id
|
|
166
|
+
// (#3280 §3 follow-up), and removing a focused DOM node drops focus to <body> as an intrinsic side
|
|
167
|
+
// effect, before this effect ever runs. `pendingFocusReturnRef` (set at answer time, and not yet
|
|
168
|
+
// cleared — the effect that clears it is a passive one, ordered after this layout effect) still
|
|
169
|
+
// remembers that focus was in this prompt, so that case is reclaimed too.
|
|
170
|
+
useLayoutEffect(() => {
|
|
171
|
+
const container = containerRef.current;
|
|
172
|
+
if (!container) return;
|
|
173
|
+
const active = document.activeElement;
|
|
174
|
+
const focusStillInside = !!(active && active !== container && container.contains(active));
|
|
175
|
+
const focusJustLeftOnRemount = pendingFocusReturnRef.current && active === document.body;
|
|
176
|
+
if (focusStillInside || focusJustLeftOnRemount) {
|
|
177
|
+
container.focus();
|
|
178
|
+
}
|
|
179
|
+
}, [promptId]);
|
|
180
|
+
// Once armed the prompt takes focus, so its keys answer it rather than type wherever the person
|
|
181
|
+
// already is — UNLESS that is an editable field, whose keystrokes must never be stolen (#3280 §1).
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!armed) return;
|
|
184
|
+
if (isEditableElement(document.activeElement)) return;
|
|
185
|
+
(hasFreeText ? fieldRef.current : containerRef.current)?.focus();
|
|
186
|
+
}, [armed, promptId, hasFreeText]);
|
|
187
|
+
// An answer given while focus was inside this prompt sends it back to the composer once no prompt is
|
|
188
|
+
// left to show it — unless another one immediately follows (handled above by keeping focus in place).
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (promptId !== undefined) {
|
|
191
|
+
pendingFocusReturnRef.current = false;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
setHasFocus(false);
|
|
195
|
+
if (pendingFocusReturnRef.current) {
|
|
196
|
+
pendingFocusReturnRef.current = false;
|
|
197
|
+
onFocusReturn?.();
|
|
198
|
+
}
|
|
199
|
+
}, [promptId, onFocusReturn]);
|
|
200
|
+
if (!prompt) return null;
|
|
201
|
+
|
|
202
|
+
// #3282 §2 (part 2): the provider-delete confirmation (`buildProviderDelete` in agent-command's
|
|
203
|
+
// `provider-command-profile-lifecycle.ts`) is now built on the shared `ConfirmDialog` (#3331)
|
|
204
|
+
// instead of the generic ask grid — a destructive-styled "Delete", Cancel focused by default, and
|
|
205
|
+
// no accidental backdrop-click dismissal. `confirmAction`'s own `CONFIRM_YES`/`CONFIRM_NO` values
|
|
206
|
+
// ('yes'/'no', from `@robota-sdk/agent-core`) are answered directly; this bypasses the rest of this
|
|
207
|
+
// component's dock/modal chrome and digit-shortcut machinery entirely, since `Dialog` already owns
|
|
208
|
+
// its own focus trap, Esc handling and focus restore.
|
|
209
|
+
if (prompt.kind === 'ask' && prompt.request.id === 'provider-delete') {
|
|
210
|
+
return (
|
|
211
|
+
<ConfirmDialog
|
|
212
|
+
open
|
|
213
|
+
title={prompt.request.title}
|
|
214
|
+
body="This removes it from your provider profiles. This can't be undone."
|
|
215
|
+
confirmLabel="Delete"
|
|
216
|
+
destructive
|
|
217
|
+
onCancel={() => onAnswerAsk(prompt.id, { type: 'cancelled' })}
|
|
218
|
+
onConfirm={() => onAnswerAsk(prompt.id, { type: 'answer', values: ['yes'] })}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// REMOTE-014 E5 (display-only): the prompt belongs to the driver whose turn raised it. Shown so the owner
|
|
224
|
+
// can tell a co-driver's tool-gate from their own — it NEVER changes who is authorized to answer (owner).
|
|
225
|
+
const requesterLabel =
|
|
226
|
+
prompt.requesterDriverId && !isSameSurface(prompt.requesterDriverId, ownDriverId)
|
|
227
|
+
? driverAttributionText(prompt.requesterDriverId)
|
|
228
|
+
: undefined;
|
|
229
|
+
|
|
230
|
+
const dock = layout === 'dock';
|
|
231
|
+
/** Records whether the answer being given leaves the prompt able to hand focus back afterwards. */
|
|
232
|
+
const rememberFocus = (): void => {
|
|
233
|
+
const container = containerRef.current;
|
|
234
|
+
const active = document.activeElement;
|
|
235
|
+
pendingFocusReturnRef.current = !!(container && active && container.contains(active));
|
|
236
|
+
};
|
|
237
|
+
const answerPermission = (id: string, result: boolean): void => {
|
|
238
|
+
rememberFocus();
|
|
239
|
+
onAnswerPermission(id, result);
|
|
240
|
+
};
|
|
241
|
+
// Every ask answer — submit, Cancel, Esc (in the field or at the dock), or picking an option while
|
|
242
|
+
// text sits untyped in the field — clears whatever the field holds first. `FreeTextField` also
|
|
243
|
+
// clears itself on its own submit/Esc paths; this is the single choke point that covers the rest
|
|
244
|
+
// (the Cancel button, the dock's Esc, and answering by option) without every call site repeating it.
|
|
245
|
+
const answerAsk = (id: string, response: TActionResponse): void => {
|
|
246
|
+
rememberFocus();
|
|
247
|
+
fieldHandleRef.current?.clear();
|
|
248
|
+
onAnswerAsk(id, response);
|
|
249
|
+
};
|
|
250
|
+
const multi = prompt.kind === 'ask' ? readMultiSelect(prompt.request) : undefined;
|
|
251
|
+
const ticks = selection?.promptId === prompt.id ? selection.values : multi?.defaults;
|
|
252
|
+
const selectedValues =
|
|
253
|
+
multi && ticks ? multi.options.map((opt) => opt.value).filter((value) => ticks.has(value)) : [];
|
|
254
|
+
const canSubmit = multi !== undefined && selectedValues.length >= multi.min;
|
|
255
|
+
const toggleOption = (value: string): void => {
|
|
256
|
+
if (!multi) return;
|
|
257
|
+
setSelection((current) => {
|
|
258
|
+
const next = new Set(current?.promptId === prompt.id ? current.values : multi.defaults);
|
|
259
|
+
if (next.has(value)) next.delete(value);
|
|
260
|
+
else if (next.size < multi.max) next.add(value);
|
|
261
|
+
return { promptId: prompt.id, values: next };
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
const submitSelection = (): void => {
|
|
265
|
+
if (canSubmit) answerAsk(prompt.id, { type: 'answer', values: selectedValues });
|
|
266
|
+
};
|
|
267
|
+
/** A click anywhere in the prompt that isn't on a button or the field itself focuses the field. */
|
|
268
|
+
const onContainerClick = (event: React.MouseEvent<HTMLDivElement>): void => {
|
|
269
|
+
if (!hasFreeText) return;
|
|
270
|
+
if ((event.target as HTMLElement).closest('button, input')) return;
|
|
271
|
+
fieldRef.current?.focus();
|
|
272
|
+
};
|
|
273
|
+
/** A mouse click answers at once; a keyboard click (`detail` 0) waits for the prompt to arm. */
|
|
274
|
+
const onButton =
|
|
275
|
+
(answer: () => void) =>
|
|
276
|
+
(event: React.MouseEvent): void => {
|
|
277
|
+
if (!armed && event.detail === 0) return;
|
|
278
|
+
answer();
|
|
279
|
+
};
|
|
280
|
+
const onDockKey = (event: React.KeyboardEvent): void => {
|
|
281
|
+
if (event.key === 'Escape') {
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
if (prompt.kind === 'permission') answerPermission(prompt.id, false);
|
|
284
|
+
else answerAsk(prompt.id, { type: 'cancelled' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// A key that reaches a prompt still arming (it kept focus from the one before) is dropped.
|
|
288
|
+
if (!armed) return;
|
|
289
|
+
if (multi && event.key === 'Enter') {
|
|
290
|
+
// Only from the prompt itself: on a focused button (a toggle, Submit, Cancel) Enter is that
|
|
291
|
+
// button's own click, and its keydown reaches this handler as well.
|
|
292
|
+
if (event.target === event.currentTarget) {
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
submitSelection();
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const index = Number(event.key) - 1;
|
|
299
|
+
if (!Number.isInteger(index) || index < 0) return;
|
|
300
|
+
if (prompt.kind === 'permission') {
|
|
301
|
+
if (index < 2) answerPermission(prompt.id, index === 0);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// #3282 §2: a destructive option (Delete) is excluded from the digit-answerable list entirely —
|
|
305
|
+
// no stray digit, including the one it happened to render at before this menu was reordered, can
|
|
306
|
+
// ever answer it.
|
|
307
|
+
const digitOptions = (prompt.request.options ?? []).filter(
|
|
308
|
+
(opt) => !isDestructiveAskOption(prompt.request.id, opt.value),
|
|
309
|
+
);
|
|
310
|
+
const option = digitOptions[index];
|
|
311
|
+
if (!option) return;
|
|
312
|
+
// A multi-select's digit ticks the option; it answers only when the ask has a single answer.
|
|
313
|
+
if (multi) toggleOption(option.value);
|
|
314
|
+
else answerAsk(prompt.id, { type: 'answer', values: [option.value] });
|
|
315
|
+
};
|
|
316
|
+
// #3288: an Edit/Write request carries the same server-built diff `tool_end` would show.
|
|
317
|
+
const hasDiff = prompt.kind === 'permission' && (prompt.diffLines?.length ?? 0) > 0;
|
|
318
|
+
const shellCommand =
|
|
319
|
+
prompt.kind === 'permission' && typeof prompt.toolArgs['command'] === 'string'
|
|
320
|
+
? prompt.toolArgs['command']
|
|
321
|
+
: undefined;
|
|
322
|
+
const isShellCommand = prompt.kind === 'permission' && prompt.toolName === 'Bash' && shellCommand !== undefined;
|
|
323
|
+
const askRequestId = prompt.kind === 'ask' ? prompt.request.id : undefined;
|
|
324
|
+
const askOptions = prompt.kind === 'ask' ? (prompt.request.options ?? []) : [];
|
|
325
|
+
// #3282 §2: Delete (in the provider profile action menu) renders apart from the rest, destructive-
|
|
326
|
+
// styled and with no digit shortcut — every other ask's options are unaffected (`destructiveAskOption`
|
|
327
|
+
// stays undefined, so `primaryAskOptions` is just `askOptions`, same order, same shortcuts).
|
|
328
|
+
const destructiveAskOption = askOptions.find((opt) => isDestructiveAskOption(askRequestId, opt.value));
|
|
329
|
+
const primaryAskOptions = destructiveAskOption
|
|
330
|
+
? askOptions.filter((opt) => opt !== destructiveAskOption)
|
|
331
|
+
: askOptions;
|
|
332
|
+
// Digits choose an option only while focus is on the prompt itself, not the field — a hint promising
|
|
333
|
+
// "1–9 choose" while the field holds focus would be wrong, since digits type there instead.
|
|
334
|
+
const askArmedHint =
|
|
335
|
+
hasFreeText && fieldFocused
|
|
336
|
+
? 'Enter to submit · Esc to cancel'
|
|
337
|
+
: multi && askOptions.length > 0
|
|
338
|
+
? '1–9 toggle · Enter submit · Esc cancel'
|
|
339
|
+
: askOptions.length > 0
|
|
340
|
+
? '1–9 choose · Esc cancel'
|
|
341
|
+
: 'Esc cancels';
|
|
342
|
+
return (
|
|
343
|
+
<div
|
|
344
|
+
className={
|
|
345
|
+
dock
|
|
346
|
+
? 'agent-ui gui-rise flex-shrink-0'
|
|
347
|
+
: 'agent-ui fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px]'
|
|
348
|
+
}
|
|
349
|
+
>
|
|
350
|
+
<div
|
|
351
|
+
ref={containerRef}
|
|
352
|
+
tabIndex={dock ? 0 : -1}
|
|
353
|
+
onKeyDown={dock ? onDockKey : undefined}
|
|
354
|
+
onClick={onContainerClick}
|
|
355
|
+
onFocus={() => setHasFocus(true)}
|
|
356
|
+
onBlur={(event) => {
|
|
357
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
358
|
+
setHasFocus(false);
|
|
359
|
+
}
|
|
360
|
+
}}
|
|
361
|
+
role="dialog"
|
|
362
|
+
aria-label="pending question"
|
|
363
|
+
data-armed={dock ? String(armed) : undefined}
|
|
364
|
+
className={`w-full rounded-2xl bg-card text-[14px] text-card-foreground shadow-[0_8px_30px_-12px_rgb(0_0_0/0.45)] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
365
|
+
dock ? 'p-4' : 'max-w-lg p-5'
|
|
366
|
+
}`}
|
|
367
|
+
>
|
|
368
|
+
{prompt.kind === 'permission' ? (
|
|
369
|
+
<>
|
|
370
|
+
<div className="flex items-start gap-3">
|
|
371
|
+
<span className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-warning/15 text-warning">
|
|
372
|
+
<ShieldAlert size={17} strokeWidth={1.9} />
|
|
373
|
+
</span>
|
|
374
|
+
<div className="min-w-0 flex-1">
|
|
375
|
+
<p className="text-[13px] text-muted-foreground">
|
|
376
|
+
Permission request
|
|
377
|
+
{requesterLabel && (
|
|
378
|
+
<>
|
|
379
|
+
{' '}
|
|
380
|
+
· <span className="text-foreground">{requesterLabel}</span>
|
|
381
|
+
</>
|
|
382
|
+
)}
|
|
383
|
+
</p>
|
|
384
|
+
{/* #3288 §2: an Edit/Write request with a server-built diff preview shows "Edit
|
|
385
|
+
<path>" (or "Background agent <label> wants to edit <path>") and the diff, in
|
|
386
|
+
place of "Allow <tool> to run?" and raw arguments.
|
|
387
|
+
Issue #3288 §1: a background agent's own request ALWAYS names it — whether or
|
|
388
|
+
not there's a diff to show — so this never reads as an unattributed ask for
|
|
389
|
+
exactly the riskiest calls (a diff-bearing Edit/Write). */}
|
|
390
|
+
{prompt.requester?.kind === 'background-agent' ? (
|
|
391
|
+
<p className="mt-0.5 text-[15px] font-medium text-foreground">
|
|
392
|
+
Background agent <span className="font-semibold">{prompt.requester.label}</span>{' '}
|
|
393
|
+
wants to{' '}
|
|
394
|
+
{hasDiff ? (
|
|
395
|
+
<>
|
|
396
|
+
{prompt.toolName.toLowerCase()}{' '}
|
|
397
|
+
<span className="font-mono font-semibold">{prompt.diffFile}</span>
|
|
398
|
+
</>
|
|
399
|
+
) : (
|
|
400
|
+
<>
|
|
401
|
+
run <span className="font-semibold">{prompt.toolName}</span>
|
|
402
|
+
</>
|
|
403
|
+
)}
|
|
404
|
+
</p>
|
|
405
|
+
) : hasDiff ? (
|
|
406
|
+
<p className="mt-0.5 text-[15px] font-medium text-foreground">
|
|
407
|
+
{prompt.toolName}{' '}
|
|
408
|
+
<span className="font-mono font-semibold">{prompt.diffFile}</span>
|
|
409
|
+
</p>
|
|
410
|
+
) : (
|
|
411
|
+
<p className="mt-0.5 text-[15px] font-medium text-foreground">
|
|
412
|
+
Allow <span className="font-semibold">{prompt.toolName}</span> to run?
|
|
413
|
+
</p>
|
|
414
|
+
)}
|
|
415
|
+
{hasDiff ? (
|
|
416
|
+
<div className="mt-2.5">
|
|
417
|
+
<DiffLines diffLines={prompt.diffLines!} />
|
|
418
|
+
</div>
|
|
419
|
+
) : isShellCommand ? (
|
|
420
|
+
<ShellCommandPreview command={shellCommand} cwd={prompt.cwd} />
|
|
421
|
+
) : (
|
|
422
|
+
<ToolArgs args={prompt.toolArgs} />
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
<div className="mt-4 flex flex-wrap items-center gap-2 pl-11">
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
aria-keyshortcuts={dock ? '1' : undefined}
|
|
430
|
+
className={PRIMARY_BUTTON}
|
|
431
|
+
onClick={onButton(() => answerPermission(prompt.id, true))}
|
|
432
|
+
>
|
|
433
|
+
<span>Allow</span>
|
|
434
|
+
{dock && <Kbd>1</Kbd>}
|
|
435
|
+
</button>
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
aria-keyshortcuts={dock ? '2' : undefined}
|
|
439
|
+
className={SECONDARY_BUTTON}
|
|
440
|
+
onClick={onButton(() => answerPermission(prompt.id, false))}
|
|
441
|
+
>
|
|
442
|
+
<span>Deny</span>
|
|
443
|
+
{dock && <Kbd>2</Kbd>}
|
|
444
|
+
</button>
|
|
445
|
+
{dock && (
|
|
446
|
+
<KeyHint hasFocus={hasFocus} armed={armed} armedText="1 allow · 2 deny · Esc deny" />
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
</>
|
|
450
|
+
) : (
|
|
451
|
+
<>
|
|
452
|
+
<div className="flex items-start gap-3">
|
|
453
|
+
<span className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
|
|
454
|
+
<MessageCircleQuestion size={17} strokeWidth={1.9} />
|
|
455
|
+
</span>
|
|
456
|
+
<div className="min-w-0 flex-1">
|
|
457
|
+
{requesterLabel && (
|
|
458
|
+
<p className="text-[13px] text-muted-foreground">
|
|
459
|
+
<span className="text-foreground">{requesterLabel}</span>
|
|
460
|
+
</p>
|
|
461
|
+
)}
|
|
462
|
+
<p className="text-[15px] font-medium text-foreground">{prompt.request.title}</p>
|
|
463
|
+
{prompt.request.description ? (
|
|
464
|
+
<p className="mt-1 leading-relaxed text-muted-foreground">
|
|
465
|
+
{prompt.request.description}
|
|
466
|
+
</p>
|
|
467
|
+
) : null}
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
<div className="mt-4 flex flex-col gap-3 pl-11">
|
|
471
|
+
{primaryAskOptions.length > 0 && (
|
|
472
|
+
<div className="flex flex-wrap items-start gap-3">
|
|
473
|
+
{primaryAskOptions.map((opt, index) => (
|
|
474
|
+
<div key={opt.value} className="flex flex-col items-start gap-0.5">
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
477
|
+
aria-keyshortcuts={dock && index < 9 ? String(index + 1) : undefined}
|
|
478
|
+
aria-pressed={multi ? selectedValues.includes(opt.value) : undefined}
|
|
479
|
+
className={
|
|
480
|
+
(multi ? selectedValues.includes(opt.value) : index === 0)
|
|
481
|
+
? PRIMARY_BUTTON
|
|
482
|
+
: SECONDARY_BUTTON
|
|
483
|
+
}
|
|
484
|
+
onClick={onButton(() =>
|
|
485
|
+
multi
|
|
486
|
+
? toggleOption(opt.value)
|
|
487
|
+
: answerAsk(prompt.id, { type: 'answer', values: [opt.value] }),
|
|
488
|
+
)}
|
|
489
|
+
>
|
|
490
|
+
{multi &&
|
|
491
|
+
(selectedValues.includes(opt.value) ? (
|
|
492
|
+
<SquareCheck size={15} strokeWidth={2} />
|
|
493
|
+
) : (
|
|
494
|
+
<Square size={15} strokeWidth={2} />
|
|
495
|
+
))}
|
|
496
|
+
<span>{opt.label}</span>
|
|
497
|
+
{dock && index < 9 && <Kbd>{index + 1}</Kbd>}
|
|
498
|
+
</button>
|
|
499
|
+
{opt.description && (
|
|
500
|
+
<p className="pl-0.5 text-[11.5px] leading-snug text-subtle">
|
|
501
|
+
{opt.description}
|
|
502
|
+
</p>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
))}
|
|
506
|
+
</div>
|
|
507
|
+
)}
|
|
508
|
+
{/* #3282 §2: Delete sits apart at the bottom, styled destructive, with no digit
|
|
509
|
+
shortcut — the audited bug was that it looked and behaved just like Switch. */}
|
|
510
|
+
{destructiveAskOption && (
|
|
511
|
+
<div className="flex flex-wrap items-start gap-3">
|
|
512
|
+
<button
|
|
513
|
+
type="button"
|
|
514
|
+
className={DESTRUCTIVE_BUTTON}
|
|
515
|
+
onClick={onButton(() =>
|
|
516
|
+
answerAsk(prompt.id, { type: 'answer', values: [destructiveAskOption.value] }),
|
|
517
|
+
)}
|
|
518
|
+
>
|
|
519
|
+
<span>{destructiveAskOption.label}</span>
|
|
520
|
+
</button>
|
|
521
|
+
</div>
|
|
522
|
+
)}
|
|
523
|
+
{hasFreeText && (
|
|
524
|
+
<div className="flex flex-col gap-1.5">
|
|
525
|
+
{askOptions.length > 0 && (
|
|
526
|
+
<p className="text-[12.5px] text-subtle">or type an answer</p>
|
|
527
|
+
)}
|
|
528
|
+
<FreeTextField
|
|
529
|
+
// Remounts on every new prompt — no value typed for one question can ever appear
|
|
530
|
+
// in the DOM for another, masked or not, since this is a fresh component instance
|
|
531
|
+
// with its own fresh `useState('')`, not a value this component reset in an effect.
|
|
532
|
+
key={prompt.id}
|
|
533
|
+
ref={fieldHandleRef}
|
|
534
|
+
inputRef={fieldRef}
|
|
535
|
+
masked={prompt.request.masked === true}
|
|
536
|
+
placeholder={prompt.request.placeholder}
|
|
537
|
+
allowEmpty={prompt.request.allowEmpty === true}
|
|
538
|
+
wrapClick={onButton}
|
|
539
|
+
onSubmit={(value) =>
|
|
540
|
+
answerAsk(prompt.id, { type: 'answer', values: [], text: value })
|
|
541
|
+
}
|
|
542
|
+
onCancel={() => answerAsk(prompt.id, { type: 'cancelled' })}
|
|
543
|
+
onFocusChange={setFieldFocused}
|
|
544
|
+
/>
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
548
|
+
{multi && (
|
|
549
|
+
<button
|
|
550
|
+
type="button"
|
|
551
|
+
className={`${PRIMARY_BUTTON} disabled:opacity-60`}
|
|
552
|
+
disabled={!canSubmit}
|
|
553
|
+
onClick={onButton(submitSelection)}
|
|
554
|
+
>
|
|
555
|
+
{canSubmit ? 'Submit' : `Submit (min ${multi.min})`}
|
|
556
|
+
</button>
|
|
557
|
+
)}
|
|
558
|
+
<button
|
|
559
|
+
type="button"
|
|
560
|
+
className={GHOST_BUTTON}
|
|
561
|
+
onClick={onButton(() => answerAsk(prompt.id, { type: 'cancelled' }))}
|
|
562
|
+
>
|
|
563
|
+
Cancel
|
|
564
|
+
</button>
|
|
565
|
+
{dock && (
|
|
566
|
+
<KeyHint hasFocus={hasFocus} armed={armed} armedText={askArmedHint} />
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
</>
|
|
571
|
+
)}
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const BUTTON =
|
|
578
|
+
'inline-flex items-center gap-2 rounded-lg px-3.5 py-1.5 text-[14px] font-medium transition-colors';
|
|
579
|
+
const PRIMARY_BUTTON = `${BUTTON} bg-primary text-primary-foreground hover:opacity-90`;
|
|
580
|
+
const SECONDARY_BUTTON = `${BUTTON} bg-raised text-foreground hover:bg-hover`;
|
|
581
|
+
const GHOST_BUTTON = `${BUTTON} text-muted-foreground hover:bg-hover hover:text-foreground`;
|
|
582
|
+
/** #3282 §2: a destructive ask option (Delete) — visually distinct from Switch/Edit/Test/Duplicate. */
|
|
583
|
+
const DESTRUCTIVE_BUTTON = `${BUTTON} bg-destructive/12 text-destructive hover:bg-destructive/20`;
|
|
584
|
+
|
|
585
|
+
/** Imperative escape hatch a parent can use to wipe the field's value without owning it. */
|
|
586
|
+
interface IFreeTextFieldHandle {
|
|
587
|
+
clear: () => void;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
interface IFreeTextFieldProps {
|
|
591
|
+
masked: boolean;
|
|
592
|
+
placeholder?: string;
|
|
593
|
+
/** An empty answer is otherwise blocked, mirroring the runtime's own text-prompt renderers. */
|
|
594
|
+
allowEmpty: boolean;
|
|
595
|
+
/** The field's own DOM node, so the parent can move focus onto it once the prompt arms. */
|
|
596
|
+
inputRef: React.RefObject<HTMLInputElement | null>;
|
|
597
|
+
/** The parent's mouse-vs-keyboard-click gate (`armed`), applied to this field's own button. */
|
|
598
|
+
wrapClick: (answer: () => void) => (event: React.MouseEvent) => void;
|
|
599
|
+
onSubmit: (value: string) => void;
|
|
600
|
+
onCancel: () => void;
|
|
601
|
+
onFocusChange: (focused: boolean) => void;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The free-text answer to one ask prompt. Its value lives ONLY here, in a component the parent
|
|
606
|
+
* mounts with `key={prompt.id}` — so a cancelled or answered prompt's typed (possibly masked, e.g.
|
|
607
|
+
* an API key) text cannot survive into another prompt's render: there is no shared state to carry it,
|
|
608
|
+
* only a fresh `useState('')` on the next prompt's fresh instance. `clear` is exposed via `ref` as a
|
|
609
|
+
* second line of defense, so the parent can wipe the value at the moment ANY answer is given (Cancel,
|
|
610
|
+
* an option pick, the dock's Esc) and not only from the paths already local to this component (submit,
|
|
611
|
+
* Esc-in-field) — belt and suspenders, not a substitute for the remount.
|
|
612
|
+
*/
|
|
613
|
+
const FreeTextField = forwardRef<IFreeTextFieldHandle, IFreeTextFieldProps>(function FreeTextField(
|
|
614
|
+
{ masked, placeholder, allowEmpty, inputRef, wrapClick, onSubmit, onCancel, onFocusChange },
|
|
615
|
+
ref,
|
|
616
|
+
) {
|
|
617
|
+
const [value, setValue] = useState('');
|
|
618
|
+
useImperativeHandle(ref, () => ({ clear: () => setValue('') }), []);
|
|
619
|
+
/** The typed value, trimmed as the runtime's own text-prompt renderers do; blocked while empty. */
|
|
620
|
+
const submit = (): void => {
|
|
621
|
+
const trimmed = value.trim();
|
|
622
|
+
if (!trimmed && !allowEmpty) return;
|
|
623
|
+
setValue('');
|
|
624
|
+
onSubmit(trimmed);
|
|
625
|
+
};
|
|
626
|
+
const cancel = (): void => {
|
|
627
|
+
setValue('');
|
|
628
|
+
onCancel();
|
|
629
|
+
};
|
|
630
|
+
/**
|
|
631
|
+
* A key that reaches the field types into it — the prompt's own shortcuts never see it (#3280 §3).
|
|
632
|
+
* The Enter that only finishes an IME composition (Korean/Japanese/Chinese) must not submit the
|
|
633
|
+
* still-uncommitted text — `isComposing` (or `keyCode` 229, on browsers that predate it) marks that
|
|
634
|
+
* Enter; the keystroke is left alone so the browser can commit the composition normally.
|
|
635
|
+
*/
|
|
636
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
637
|
+
event.stopPropagation();
|
|
638
|
+
if (event.key === 'Enter') {
|
|
639
|
+
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
640
|
+
event.preventDefault();
|
|
641
|
+
submit();
|
|
642
|
+
} else if (event.key === 'Escape') {
|
|
643
|
+
event.preventDefault();
|
|
644
|
+
cancel();
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
return (
|
|
648
|
+
<div className="flex items-center gap-2">
|
|
649
|
+
<input
|
|
650
|
+
ref={inputRef}
|
|
651
|
+
type={masked ? 'password' : 'text'}
|
|
652
|
+
autoComplete="off"
|
|
653
|
+
aria-label="answer"
|
|
654
|
+
placeholder={placeholder}
|
|
655
|
+
value={value}
|
|
656
|
+
onChange={(event) => setValue(event.target.value)}
|
|
657
|
+
onKeyDown={onKeyDown}
|
|
658
|
+
onFocus={() => onFocusChange(true)}
|
|
659
|
+
onBlur={() => onFocusChange(false)}
|
|
660
|
+
className="min-w-0 flex-1 rounded-lg bg-sidebar px-3 py-1.5 text-[14px] text-foreground placeholder:text-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
661
|
+
/>
|
|
662
|
+
<button
|
|
663
|
+
type="button"
|
|
664
|
+
className={PRIMARY_BUTTON}
|
|
665
|
+
disabled={!value.trim() && !allowEmpty}
|
|
666
|
+
onClick={wrapClick(submit)}
|
|
667
|
+
>
|
|
668
|
+
Continue
|
|
669
|
+
</button>
|
|
670
|
+
</div>
|
|
671
|
+
);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
function Kbd({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
675
|
+
return (
|
|
676
|
+
// Hidden from the accessible name: the button is "Allow", and the hint line says which key answers.
|
|
677
|
+
<kbd
|
|
678
|
+
aria-hidden="true"
|
|
679
|
+
className="rounded bg-foreground/10 px-1 font-mono text-[11px] leading-[1.45] opacity-70"
|
|
680
|
+
>
|
|
681
|
+
{children}
|
|
682
|
+
</kbd>
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function KeyHint({
|
|
687
|
+
hasFocus,
|
|
688
|
+
armed,
|
|
689
|
+
armedText,
|
|
690
|
+
}: {
|
|
691
|
+
hasFocus: boolean;
|
|
692
|
+
armed: boolean;
|
|
693
|
+
/** What the prompt's keys do once armed — differs by kind and by whether it offers free text. */
|
|
694
|
+
armedText: string;
|
|
695
|
+
}): React.ReactElement {
|
|
696
|
+
const text = !hasFocus
|
|
697
|
+
? 'Click to answer, or press Shift+Tab to use the keys'
|
|
698
|
+
: armed
|
|
699
|
+
? armedText
|
|
700
|
+
: 'keys answer in a moment · click to answer now';
|
|
701
|
+
return <p className="ml-auto text-[12.5px] text-subtle">{text}</p>;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Everything the tool was asked to do, since that is what the owner approves: a command line first, as it
|
|
706
|
+
* is, then every other argument (where it runs, what it is fed) as `key: value`. Nothing is cut; a long
|
|
707
|
+
* block scrolls.
|
|
708
|
+
*/
|
|
709
|
+
function ToolArgs({ args }: { args: unknown }): React.ReactElement | null {
|
|
710
|
+
if (args === null || typeof args !== 'object') return null;
|
|
711
|
+
const entries = Object.entries(args as Record<string, unknown>);
|
|
712
|
+
if (entries.length === 0) return null;
|
|
713
|
+
const commandKey = entries.find(
|
|
714
|
+
([key, value]) => (key === 'command' || key === 'cmd') && typeof value === 'string',
|
|
715
|
+
)?.[0];
|
|
716
|
+
const rest = entries.filter(([key]) => key !== commandKey);
|
|
717
|
+
const format = (value: unknown): string =>
|
|
718
|
+
typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
719
|
+
return (
|
|
720
|
+
<div className="mt-2.5 max-h-48 overflow-auto rounded-lg bg-sidebar px-3 py-2 font-mono text-[12.5px] leading-relaxed">
|
|
721
|
+
{commandKey ? (
|
|
722
|
+
<pre className="whitespace-pre-wrap break-all text-foreground">
|
|
723
|
+
{(args as Record<string, string>)[commandKey]}
|
|
724
|
+
</pre>
|
|
725
|
+
) : null}
|
|
726
|
+
{rest.length > 0 ? (
|
|
727
|
+
<dl className={`text-muted-foreground ${commandKey ? 'mt-1.5' : ''}`}>
|
|
728
|
+
{rest.map(([key, value]) => (
|
|
729
|
+
<div key={key} className="flex gap-2">
|
|
730
|
+
<dt className="flex-shrink-0 text-subtle">{key}:</dt>
|
|
731
|
+
<dd className="min-w-0 whitespace-pre-wrap break-all">{format(value)}</dd>
|
|
732
|
+
</div>
|
|
733
|
+
))}
|
|
734
|
+
</dl>
|
|
735
|
+
) : null}
|
|
736
|
+
</div>
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* #3288: a Shell request shows just its command — and the working directory only when the server
|
|
742
|
+
* says it differs from the workspace — instead of every raw argument.
|
|
743
|
+
*/
|
|
744
|
+
function ShellCommandPreview({
|
|
745
|
+
command,
|
|
746
|
+
cwd,
|
|
747
|
+
}: {
|
|
748
|
+
command: string;
|
|
749
|
+
cwd?: string;
|
|
750
|
+
}): React.ReactElement {
|
|
751
|
+
return (
|
|
752
|
+
<div className="mt-2.5 max-h-48 overflow-auto rounded-lg bg-sidebar px-3 py-2 font-mono text-[12.5px] leading-relaxed">
|
|
753
|
+
<pre className="whitespace-pre-wrap break-all text-foreground">{command}</pre>
|
|
754
|
+
{cwd && (
|
|
755
|
+
<p className="mt-1.5 text-muted-foreground">
|
|
756
|
+
<span className="text-subtle">in </span>
|
|
757
|
+
{cwd}
|
|
758
|
+
</p>
|
|
759
|
+
)}
|
|
760
|
+
</div>
|
|
761
|
+
);
|
|
762
|
+
}
|