@1agh/maude 0.45.0 → 0.45.2
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/apps/studio/acp/bridge.ts +585 -73
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +802 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +71 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +648 -11
- package/apps/studio/dist/client.bundle.js +1596 -1594
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +33 -2
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/whats-new.json +34 -0
- package/cli/commands/design.mjs +107 -2
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// The inline form-elicitation card (feature-acp-ask-user-question) — renders
|
|
2
|
+
// whatever `requestedSchema.properties` the agent actually sent, generically
|
|
3
|
+
// (works for both AskUserQuestion's `question_N`/`question_N_custom` fields
|
|
4
|
+
// and any other well-formed elicitation form an MCP server sends over the SAME
|
|
5
|
+
// wire mechanism — see the plan's Research section). Same card-slot/focus-trap
|
|
6
|
+
// shape as PermissionPrompt.jsx, but a different renderer: PermissionPrompt
|
|
7
|
+
// renders a flat `options[]`; this renders a JSON-Schema-driven form.
|
|
8
|
+
//
|
|
9
|
+
// Multiple questions render as a one-at-a-time wizard (Back/Next), not
|
|
10
|
+
// stacked vertically — dogfooding found a wall of stacked question groups
|
|
11
|
+
// hard to scan; the CLI's own AskUserQuestion picker is one question at a
|
|
12
|
+
// time too.
|
|
13
|
+
|
|
14
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
buildElicitationContent,
|
|
18
|
+
looksLikeSecretRequest,
|
|
19
|
+
parseElicitationSchema,
|
|
20
|
+
} from './acp-elicitation.js';
|
|
21
|
+
|
|
22
|
+
// The exact literal `askUserQuestionsToCreateRequest` falls back to as the
|
|
23
|
+
// card `message` whenever an AskUserQuestion call carries more than one
|
|
24
|
+
// question (confirmed on disk against the installed adapter source) — never
|
|
25
|
+
// meaningful content, just "there's more than one question below."
|
|
26
|
+
const GENERIC_MULTI_QUESTION_MESSAGE = 'Please answer the following questions.';
|
|
27
|
+
|
|
28
|
+
function isAnswered(q, answers) {
|
|
29
|
+
const custom = q.customFieldId ? answers[q.customFieldId] : undefined;
|
|
30
|
+
if (typeof custom === 'string' && custom.trim() !== '') return true;
|
|
31
|
+
if (q.kind === 'multi') return Array.isArray(answers[q.id]) && answers[q.id].length > 0;
|
|
32
|
+
return typeof answers[q.id] === 'string' && answers[q.id] !== '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Question({
|
|
36
|
+
q,
|
|
37
|
+
answers,
|
|
38
|
+
setAnswer,
|
|
39
|
+
customOpen,
|
|
40
|
+
onChooseOption,
|
|
41
|
+
onChooseOther,
|
|
42
|
+
onToggleCustom,
|
|
43
|
+
groupName,
|
|
44
|
+
cardSecretShaped,
|
|
45
|
+
}) {
|
|
46
|
+
// SECURITY (ethical-hacker finding) — Maude only ever declares the `form`
|
|
47
|
+
// elicitation capability, never `url` (DDR-180's Open decisions), which
|
|
48
|
+
// makes this free-text box the ONLY channel any connected MCP server can
|
|
49
|
+
// ever use to ask for a real credential — the MCP spec's own guidance is
|
|
50
|
+
// that url-mode exists specifically so a genuine secret request never has
|
|
51
|
+
// to pass through here at all. This doesn't block anything; it just makes a
|
|
52
|
+
// credential-shaped ask LOOK like one (masked input + a visible warning),
|
|
53
|
+
// the same pause a real password field gives a user, instead of rendering
|
|
54
|
+
// identically to "which color do you like?".
|
|
55
|
+
const secretShaped = cardSecretShaped || q.secretShaped;
|
|
56
|
+
// `title` (AskUserQuestion's `header` — a short ≤12-char chip like "Barva")
|
|
57
|
+
// and `description` (the full question text, only carried per-field once a
|
|
58
|
+
// call has more than one question — a single-question call puts the full
|
|
59
|
+
// text in the card's own `message` instead) are BOTH real, independent
|
|
60
|
+
// pieces of the schema — a single `title || description` fallback was
|
|
61
|
+
// silently dropping the actual question text whenever a header existed
|
|
62
|
+
// (dogfooding finding: legends showed only short chips like "Barva"/
|
|
63
|
+
// "Projekty"/"Styl" with no visible question). Show both when present.
|
|
64
|
+
return (
|
|
65
|
+
<fieldset className="chat-elicit-question" data-testid={`chat-elicit-question-${q.id}`}>
|
|
66
|
+
{q.title || q.description ? (
|
|
67
|
+
<legend className="chat-elicit-question-hd">
|
|
68
|
+
{q.title ? <span className="chat-elicit-question-chip">{q.title}</span> : null}
|
|
69
|
+
{q.description || null}
|
|
70
|
+
</legend>
|
|
71
|
+
) : null}
|
|
72
|
+
{q.kind === 'single' ? (
|
|
73
|
+
<div className="chat-elicit-options">
|
|
74
|
+
{q.options.map((o, i) => (
|
|
75
|
+
<label
|
|
76
|
+
key={o.value}
|
|
77
|
+
className="chat-elicit-option"
|
|
78
|
+
data-testid={`chat-elicit-option-${q.id}-${i}`}
|
|
79
|
+
>
|
|
80
|
+
<input
|
|
81
|
+
type="radio"
|
|
82
|
+
name={groupName}
|
|
83
|
+
checked={!customOpen && answers[q.id] === o.value}
|
|
84
|
+
onChange={() => onChooseOption(o.value)}
|
|
85
|
+
/>
|
|
86
|
+
<span className="chat-elicit-option-body">
|
|
87
|
+
<span>
|
|
88
|
+
{o.label}
|
|
89
|
+
{o.description ? (
|
|
90
|
+
<span className="chat-elicit-option-desc"> — {o.description}</span>
|
|
91
|
+
) : null}
|
|
92
|
+
</span>
|
|
93
|
+
{o.preview ? <pre className="chat-elicit-option-preview">{o.preview}</pre> : null}
|
|
94
|
+
</span>
|
|
95
|
+
</label>
|
|
96
|
+
))}
|
|
97
|
+
{/* "Other" rides IN the radio group, not a separate hidden toggle
|
|
98
|
+
link below it — dogfooding found the link easy to miss entirely
|
|
99
|
+
(image: a muted collapsed box read as inert, not clickable) AND
|
|
100
|
+
found the OLD design (link + still-checked radio) actively
|
|
101
|
+
confusing: the picked radio stayed visually selected while
|
|
102
|
+
typing, implying both would be sent when only the custom text
|
|
103
|
+
was. A real radio, in the SAME mutually-exclusive group, makes
|
|
104
|
+
the actual either/or truth (one field, one answer — a real
|
|
105
|
+
adapter constraint, not a UI choice) visible for free: picking
|
|
106
|
+
a real option or "Other" naturally un-picks the other. */}
|
|
107
|
+
{q.customFieldId ? (
|
|
108
|
+
<label
|
|
109
|
+
className="chat-elicit-option chat-elicit-option--other"
|
|
110
|
+
data-testid={`chat-elicit-option-${q.id}-other`}
|
|
111
|
+
>
|
|
112
|
+
<input type="radio" name={groupName} checked={customOpen} onChange={onChooseOther} />
|
|
113
|
+
<span className="chat-elicit-option-body">
|
|
114
|
+
<span>Other — type your own answer</span>
|
|
115
|
+
</span>
|
|
116
|
+
</label>
|
|
117
|
+
) : null}
|
|
118
|
+
{q.customFieldId && customOpen ? (
|
|
119
|
+
<input
|
|
120
|
+
type={secretShaped ? 'password' : 'text'}
|
|
121
|
+
className="chat-elicit-text chat-elicit-custom chat-elicit-custom--inline"
|
|
122
|
+
placeholder="Type your own answer…"
|
|
123
|
+
autoFocus
|
|
124
|
+
value={answers[q.customFieldId] || ''}
|
|
125
|
+
onChange={(e) => setAnswer(q.customFieldId, e.target.value)}
|
|
126
|
+
data-testid={`chat-elicit-custom-${q.id}`}
|
|
127
|
+
/>
|
|
128
|
+
) : null}
|
|
129
|
+
</div>
|
|
130
|
+
) : null}
|
|
131
|
+
{q.kind === 'multi' ? (
|
|
132
|
+
<div className="chat-elicit-options">
|
|
133
|
+
{q.options.map((o, i) => {
|
|
134
|
+
const selected = Array.isArray(answers[q.id]) ? answers[q.id] : [];
|
|
135
|
+
return (
|
|
136
|
+
<label
|
|
137
|
+
key={o.value}
|
|
138
|
+
className="chat-elicit-option"
|
|
139
|
+
data-testid={`chat-elicit-option-${q.id}-${i}`}
|
|
140
|
+
>
|
|
141
|
+
<input
|
|
142
|
+
type="checkbox"
|
|
143
|
+
checked={selected.includes(o.value)}
|
|
144
|
+
onChange={(e) =>
|
|
145
|
+
setAnswer(
|
|
146
|
+
q.id,
|
|
147
|
+
e.target.checked
|
|
148
|
+
? [...selected, o.value]
|
|
149
|
+
: selected.filter((v) => v !== o.value)
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
/>
|
|
153
|
+
<span className="chat-elicit-option-body">
|
|
154
|
+
<span>
|
|
155
|
+
{o.label}
|
|
156
|
+
{o.description ? (
|
|
157
|
+
<span className="chat-elicit-option-desc"> — {o.description}</span>
|
|
158
|
+
) : null}
|
|
159
|
+
</span>
|
|
160
|
+
{o.preview ? <pre className="chat-elicit-option-preview">{o.preview}</pre> : null}
|
|
161
|
+
</span>
|
|
162
|
+
</label>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</div>
|
|
166
|
+
) : null}
|
|
167
|
+
{q.kind === 'text' ? (
|
|
168
|
+
<input
|
|
169
|
+
type={secretShaped ? 'password' : 'text'}
|
|
170
|
+
className="chat-elicit-text"
|
|
171
|
+
value={answers[q.id] || ''}
|
|
172
|
+
onChange={(e) => setAnswer(q.id, e.target.value)}
|
|
173
|
+
data-testid={`chat-elicit-text-${q.id}`}
|
|
174
|
+
/>
|
|
175
|
+
) : null}
|
|
176
|
+
{/* Multi-select keeps the collapsed-toggle shape (checkboxes aren't
|
|
177
|
+
mutually exclusive, so there's no natural "Other" radio to fold
|
|
178
|
+
into) — but it genuinely COMBINES rather than overrides (see
|
|
179
|
+
buildElicitationContent), so the note explains that instead of
|
|
180
|
+
warning about a replacement that isn't happening here. */}
|
|
181
|
+
{q.kind === 'multi' && q.customFieldId ? (
|
|
182
|
+
customOpen ? (
|
|
183
|
+
<div className="chat-elicit-custom-wrap">
|
|
184
|
+
<input
|
|
185
|
+
type={secretShaped ? 'password' : 'text'}
|
|
186
|
+
className="chat-elicit-text chat-elicit-custom"
|
|
187
|
+
placeholder="Type your own answer…"
|
|
188
|
+
autoFocus
|
|
189
|
+
value={answers[q.customFieldId] || ''}
|
|
190
|
+
onChange={(e) => setAnswer(q.customFieldId, e.target.value)}
|
|
191
|
+
/>
|
|
192
|
+
<p className="chat-elicit-custom-note">Added to your selections above.</p>
|
|
193
|
+
</div>
|
|
194
|
+
) : (
|
|
195
|
+
<button type="button" className="chat-elicit-custom-toggle" onClick={onToggleCustom}>
|
|
196
|
+
Add your own answer to your selections?
|
|
197
|
+
</button>
|
|
198
|
+
)
|
|
199
|
+
) : null}
|
|
200
|
+
{secretShaped ? (
|
|
201
|
+
<p className="chat-elicit-secret-warning" data-testid="chat-elicit-secret-warning">
|
|
202
|
+
This looks like it's asking for a password, key, or other credential — Maude never
|
|
203
|
+
needs your real ones here.
|
|
204
|
+
</p>
|
|
205
|
+
) : null}
|
|
206
|
+
</fieldset>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default function ElicitationPrompt({ request, onRespond }) {
|
|
211
|
+
const cardRef = useRef(null);
|
|
212
|
+
const questions = useMemo(() => parseElicitationSchema(request.requestedSchema), [request]);
|
|
213
|
+
const [answers, setAnswers] = useState({});
|
|
214
|
+
const [customOpenIds, setCustomOpenIds] = useState(() => new Set());
|
|
215
|
+
const [step, setStep] = useState(0);
|
|
216
|
+
const isWizard = questions.length > 1;
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
setAnswers({});
|
|
220
|
+
setCustomOpenIds(new Set());
|
|
221
|
+
setStep(0);
|
|
222
|
+
}, [request.id]);
|
|
223
|
+
|
|
224
|
+
// Focus the card ONCE per request, on mount only — NOT on every answer
|
|
225
|
+
// change. Dogfooding found every keystroke in a free-text field lost focus:
|
|
226
|
+
// this used to live in the same effect as the keydown listener below, whose
|
|
227
|
+
// deps included `answers`, so it re-ran (and re-called `.focus()` on the
|
|
228
|
+
// outer card div) on every character typed, yanking focus off the input
|
|
229
|
+
// mid-type. Splitting it out fixes that — the keydown listener still needs
|
|
230
|
+
// fresh `canSubmit`/`answers` closures, but re-adding a listener doesn't
|
|
231
|
+
// need to also steal focus.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
cardRef.current?.focus();
|
|
234
|
+
}, [request.id]);
|
|
235
|
+
|
|
236
|
+
const setAnswer = (id, value) => setAnswers((prev) => ({ ...prev, [id]: value }));
|
|
237
|
+
const openCustom = (id) => setCustomOpenIds((prev) => new Set(prev).add(id));
|
|
238
|
+
const closeCustom = (id) =>
|
|
239
|
+
setCustomOpenIds((prev) => {
|
|
240
|
+
if (!prev.has(id)) return prev;
|
|
241
|
+
const next = new Set(prev);
|
|
242
|
+
next.delete(id);
|
|
243
|
+
return next;
|
|
244
|
+
});
|
|
245
|
+
// Multi-select's collapsed toggle: closing it must also clear whatever was
|
|
246
|
+
// typed — otherwise the text stays in `answers` invisibly and still gets
|
|
247
|
+
// folded into the submitted content even though the field is hidden again
|
|
248
|
+
// (the same class of stale-state bug `onChooseOption` below exists to close
|
|
249
|
+
// for single-select).
|
|
250
|
+
const toggleCustom = (id) => {
|
|
251
|
+
if (customOpenIds.has(id)) {
|
|
252
|
+
setAnswer(id, '');
|
|
253
|
+
closeCustom(id);
|
|
254
|
+
} else {
|
|
255
|
+
openCustom(id);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
// Single-select's "Other" now rides in the radio group (see Question) —
|
|
259
|
+
// picking a real option must clear any stale custom text so it can never
|
|
260
|
+
// silently win at submit time (buildElicitationContent only checks
|
|
261
|
+
// "is there non-empty custom text", not "is Other currently selected").
|
|
262
|
+
// Picking "Other" clears the real pick the same way, for the same reason —
|
|
263
|
+
// harmless since buildElicitationContent ignores `answers[q.id]` once
|
|
264
|
+
// custom is non-empty, but keeps the two paths symmetric and correct even
|
|
265
|
+
// before the user has typed anything into Other yet.
|
|
266
|
+
const chooseOption = (q, value) => {
|
|
267
|
+
setAnswers((prev) => ({
|
|
268
|
+
...prev,
|
|
269
|
+
[q.id]: value,
|
|
270
|
+
...(q.customFieldId ? { [q.customFieldId]: '' } : {}),
|
|
271
|
+
}));
|
|
272
|
+
if (q.customFieldId) closeCustom(q.customFieldId);
|
|
273
|
+
};
|
|
274
|
+
const chooseOther = (q) => {
|
|
275
|
+
setAnswer(q.id, '');
|
|
276
|
+
openCustom(q.customFieldId);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const canSubmit = questions.filter((q) => q.required).every((q) => isAnswered(q, answers));
|
|
280
|
+
const currentQuestion = isWizard ? questions[step] : null;
|
|
281
|
+
const canAdvance = !currentQuestion || !currentQuestion.required || isAnswered(currentQuestion, answers);
|
|
282
|
+
const isLastStep = !isWizard || step === questions.length - 1;
|
|
283
|
+
|
|
284
|
+
const submit = () => {
|
|
285
|
+
if (!canSubmit) return;
|
|
286
|
+
onRespond({ action: 'accept', content: buildElicitationContent(questions, answers) });
|
|
287
|
+
};
|
|
288
|
+
const skip = () => onRespond({ action: 'decline' });
|
|
289
|
+
const cancel = () => onRespond({ action: 'cancel' });
|
|
290
|
+
const back = () => setStep((s) => Math.max(0, s - 1));
|
|
291
|
+
const next = () => {
|
|
292
|
+
if (!canAdvance) return;
|
|
293
|
+
setStep((s) => Math.min(questions.length - 1, s + 1));
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
function onKey(e) {
|
|
298
|
+
if (e.key === 'Escape') {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
cancel();
|
|
301
|
+
} else if (e.key === 'Enter' && e.target?.tagName !== 'BUTTON') {
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
if (isLastStep) submit();
|
|
304
|
+
else next();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const el = cardRef.current;
|
|
308
|
+
el?.addEventListener('keydown', onKey);
|
|
309
|
+
return () => el?.removeEventListener('keydown', onKey);
|
|
310
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
311
|
+
}, [request.id, canSubmit, canAdvance, isLastStep, answers, step]);
|
|
312
|
+
|
|
313
|
+
// SECURITY (ethical-hacker + security-auditor finding) — this same card
|
|
314
|
+
// renders BOTH the built-in AskUserQuestion tool's questions AND any
|
|
315
|
+
// connected MCP server's form elicitation (see DDR-180); the wire protocol
|
|
316
|
+
// carries no field that reliably distinguishes which. Never brand this as
|
|
317
|
+
// "from Claude" — that would misattribute an MCP-server-authored form
|
|
318
|
+
// (potentially phishing-shaped free text) as trusted first-party chrome.
|
|
319
|
+
// `toolCallId`, when present, ties this to a specific tool call the
|
|
320
|
+
// transcript already rendered a card for just above — the honest,
|
|
321
|
+
// independently-verifiable signal, instead of an unearned brand claim.
|
|
322
|
+
const attribution = request.toolCallId
|
|
323
|
+
? 'Requested by the tool call above'
|
|
324
|
+
: 'Requested by a connected tool (not tied to a visible step)';
|
|
325
|
+
|
|
326
|
+
const visibleQuestions = isWizard ? (currentQuestion ? [currentQuestion] : []) : questions;
|
|
327
|
+
// The card's own top-level `message` can itself read as a credential ask
|
|
328
|
+
// (e.g. a single-question call whose only text is "Enter your API key") —
|
|
329
|
+
// checked separately from each question's own title/description since a
|
|
330
|
+
// single-question AskUserQuestion call puts the real text in `message`,
|
|
331
|
+
// not in the per-field schema (see the showTopMessage comment below).
|
|
332
|
+
const cardSecretShaped = looksLikeSecretRequest(request.message);
|
|
333
|
+
|
|
334
|
+
// A multi-question AskUserQuestion call's top-level `message` is always the
|
|
335
|
+
// SAME generic boilerplate string ("Please answer the following
|
|
336
|
+
// questions." — askUserQuestionsToCreateRequest's literal fallback,
|
|
337
|
+
// confirmed on disk); the real per-question text already renders inside
|
|
338
|
+
// each step. Showing both stacked one above the other read as cluttered
|
|
339
|
+
// ("vypadá dost ošklivě" dogfooding feedback) — skip the boilerplate line
|
|
340
|
+
// and let the step counter carry that slot instead. A genuinely custom
|
|
341
|
+
// top-level message (single-question calls, or a non-AskUserQuestion MCP
|
|
342
|
+
// form) still gets its own prominent header line.
|
|
343
|
+
const showTopMessage =
|
|
344
|
+
!!request.message && (!isWizard || request.message !== GENERIC_MULTI_QUESTION_MESSAGE);
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<div
|
|
348
|
+
className="chat-elicit"
|
|
349
|
+
role="alertdialog"
|
|
350
|
+
aria-label="Input requested"
|
|
351
|
+
tabIndex={-1}
|
|
352
|
+
ref={cardRef}
|
|
353
|
+
data-testid="chat-elicit-prompt"
|
|
354
|
+
>
|
|
355
|
+
<div className="chat-elicit-hd">
|
|
356
|
+
{showTopMessage ? <b>{request.message}</b> : null}
|
|
357
|
+
<div className="chat-elicit-meta">
|
|
358
|
+
{isWizard ? (
|
|
359
|
+
<span data-testid="chat-elicit-step">
|
|
360
|
+
Question {step + 1} of {questions.length}
|
|
361
|
+
</span>
|
|
362
|
+
) : null}
|
|
363
|
+
<span className="chat-elicit-attribution">{attribution}</span>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
{visibleQuestions.map((q) => (
|
|
367
|
+
<Question
|
|
368
|
+
key={q.id}
|
|
369
|
+
q={q}
|
|
370
|
+
answers={answers}
|
|
371
|
+
setAnswer={setAnswer}
|
|
372
|
+
customOpen={customOpenIds.has(q.customFieldId)}
|
|
373
|
+
onChooseOption={(value) => chooseOption(q, value)}
|
|
374
|
+
onChooseOther={() => chooseOther(q)}
|
|
375
|
+
onToggleCustom={() => toggleCustom(q.customFieldId)}
|
|
376
|
+
groupName={`elicit-${request.id}-${q.id}`}
|
|
377
|
+
cardSecretShaped={cardSecretShaped}
|
|
378
|
+
/>
|
|
379
|
+
))}
|
|
380
|
+
<div className="chat-elicit-actions">
|
|
381
|
+
{isWizard && step > 0 ? (
|
|
382
|
+
<button type="button" className="btn" onClick={back} data-testid="chat-elicit-back">
|
|
383
|
+
Back
|
|
384
|
+
</button>
|
|
385
|
+
) : null}
|
|
386
|
+
{isLastStep ? (
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
className="btn btn--primary"
|
|
390
|
+
disabled={!canSubmit}
|
|
391
|
+
onClick={submit}
|
|
392
|
+
data-testid="chat-elicit-submit"
|
|
393
|
+
>
|
|
394
|
+
Submit
|
|
395
|
+
</button>
|
|
396
|
+
) : (
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
className="btn btn--primary"
|
|
400
|
+
disabled={!canAdvance}
|
|
401
|
+
onClick={next}
|
|
402
|
+
data-testid="chat-elicit-next"
|
|
403
|
+
>
|
|
404
|
+
Next
|
|
405
|
+
</button>
|
|
406
|
+
)}
|
|
407
|
+
<span className="chat-elicit-actions-spacer" />
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
className="btn"
|
|
411
|
+
onClick={skip}
|
|
412
|
+
title="Let Claude continue without an answer"
|
|
413
|
+
data-testid="chat-elicit-skip"
|
|
414
|
+
>
|
|
415
|
+
Skip
|
|
416
|
+
</button>
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
className="btn btn--danger"
|
|
420
|
+
onClick={cancel}
|
|
421
|
+
title="Stop this question — the action it was part of is aborted too"
|
|
422
|
+
data-testid="chat-elicit-cancel"
|
|
423
|
+
>
|
|
424
|
+
Cancel
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The inline approve/deny card (Milestone B — retires DDR-125 F2's blanket
|
|
2
|
+
// auto-approve). Renders whatever `options[]` the agent actually offered —
|
|
3
|
+
// NOT a fixed Allow/Reject pair — because ExitPlanMode rides this SAME
|
|
4
|
+
// requestPermission path with its own multi-way option set ("Yes, and use
|
|
5
|
+
// auto mode" / "Yes, and auto-accept edits" / "No, keep planning", …). Only
|
|
6
|
+
// mounted while a request is pending for the ACTIVE chat (ChatThread gates
|
|
7
|
+
// this — one card at a time, oldest first).
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef } from 'react';
|
|
10
|
+
|
|
11
|
+
function toolTarget(toolCall) {
|
|
12
|
+
const raw = toolCall?.rawInput;
|
|
13
|
+
const fromInput =
|
|
14
|
+
raw && typeof raw === 'object' ? raw.path || raw.file_path || raw.abs_path || raw.filePath : undefined;
|
|
15
|
+
const fromLocation = Array.isArray(toolCall?.locations) ? toolCall.locations[0]?.path : undefined;
|
|
16
|
+
const path = fromInput || fromLocation;
|
|
17
|
+
return path ? String(path).split('/').pop() : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Visual bucket for a PermissionOptionKind — drives which button style reads
|
|
21
|
+
// as "the safe default" (allow-once) vs "the escape hatch" (reject). Note
|
|
22
|
+
// `allow_always` is deliberately NOT primary-styled — see `pickDefaultAllow`
|
|
23
|
+
// below for why.
|
|
24
|
+
function kindClass(kind) {
|
|
25
|
+
if (kind === 'reject_once' || kind === 'reject_always') return 'btn btn--danger';
|
|
26
|
+
if (kind === 'allow_once') return 'btn btn--primary';
|
|
27
|
+
return 'btn';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// SECURITY (ethical-hacker finding) — pure, exported for direct unit testing
|
|
31
|
+
// (test/acp-permission-prompt.test.ts) without needing to render the
|
|
32
|
+
// component. Prefers `allow_once` over `allow_always` as the default (Enter
|
|
33
|
+
// key + visually primary button). The adapter's own option ordering for BOTH
|
|
34
|
+
// a routine tool call and an ExitPlanMode request happens to list the
|
|
35
|
+
// most-permissive standing-exemption option FIRST (`allow_always`, or
|
|
36
|
+
// ExitPlanMode's `auto` mode) — a plain `.find()` over an unordered
|
|
37
|
+
// preference grabbed whichever came first, which meant Enter/the one bold
|
|
38
|
+
// button on an ORDINARY tool-call card silently granted a session-scoped
|
|
39
|
+
// standing exemption instead of a one-time approval, and on an ExitPlanMode
|
|
40
|
+
// card silently flipped the whole session into an unattended auto mode
|
|
41
|
+
// (after which `requestPermission` is never called again — DDR-179's own
|
|
42
|
+
// bypassPermissions short-circuit). Only fall back to `allow_always` when no
|
|
43
|
+
// once-only option was offered at all, so there's always SOME allow default
|
|
44
|
+
// rather than none.
|
|
45
|
+
export function pickDefaultAllow(options) {
|
|
46
|
+
const list = Array.isArray(options) ? options : [];
|
|
47
|
+
return (
|
|
48
|
+
list.find((o) => o?.kind === 'allow_once') ?? list.find((o) => o?.kind === 'allow_always') ?? null
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function pickDefaultReject(options) {
|
|
53
|
+
const list = Array.isArray(options) ? options : [];
|
|
54
|
+
return list.find((o) => o?.kind === 'reject_once' || o?.kind === 'reject_always') ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function PermissionPrompt({ request, onRespond, queueLength = 1 }) {
|
|
58
|
+
const cardRef = useRef(null);
|
|
59
|
+
const { toolCall, options } = request;
|
|
60
|
+
const target = toolTarget(toolCall);
|
|
61
|
+
|
|
62
|
+
const firstAllow = pickDefaultAllow(options);
|
|
63
|
+
const firstReject = pickDefaultReject(options);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
cardRef.current?.focus();
|
|
67
|
+
function onKey(e) {
|
|
68
|
+
if (e.key === 'Enter' && firstAllow) {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
onRespond(firstAllow.optionId);
|
|
71
|
+
} else if (e.key === 'Escape') {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
onRespond(firstReject ? firstReject.optionId : 'cancelled');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const el = cardRef.current;
|
|
77
|
+
el?.addEventListener('keydown', onKey);
|
|
78
|
+
return () => el?.removeEventListener('keydown', onKey);
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [request.id]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
className="chat-perm"
|
|
85
|
+
role="alertdialog"
|
|
86
|
+
aria-label="Permission request"
|
|
87
|
+
tabIndex={-1}
|
|
88
|
+
ref={cardRef}
|
|
89
|
+
data-testid="chat-permission-prompt"
|
|
90
|
+
>
|
|
91
|
+
<div className="chat-perm-hd">
|
|
92
|
+
<b title={toolCall?.title || 'Tool call'}>{toolCall?.title || 'Tool call'}</b>
|
|
93
|
+
{target ? <span className="chat-tool-path">{target}</span> : null}
|
|
94
|
+
{/* SECURITY (ethical-hacker finding) — a growing backlog was
|
|
95
|
+
invisible (always "1 of 1"), which is what manufactures the
|
|
96
|
+
reflexive-Enter-mashing precondition the wrong-default bug
|
|
97
|
+
depended on. Now visible whenever more than one is queued. */}
|
|
98
|
+
{queueLength > 1 ? (
|
|
99
|
+
<span className="chat-perm-queue" data-testid="chat-perm-queue">
|
|
100
|
+
+{queueLength - 1} more waiting
|
|
101
|
+
</span>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
<p className="chat-perm-body">Claude wants permission to continue. Choose an option:</p>
|
|
105
|
+
<div className="chat-perm-actions">
|
|
106
|
+
{options.map((o) => (
|
|
107
|
+
<button
|
|
108
|
+
key={o.optionId}
|
|
109
|
+
type="button"
|
|
110
|
+
className={kindClass(o.kind)}
|
|
111
|
+
onClick={() => onRespond(o.optionId)}
|
|
112
|
+
>
|
|
113
|
+
{o.name}
|
|
114
|
+
</button>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -92,7 +92,11 @@ const SIGNIN_POLL_MAX_MS = 120000;
|
|
|
92
92
|
// The installer itself is fire-and-forget server-side (avoids Bun's default
|
|
93
93
|
// 10s idle timeout on a slow network) — poll for its result the same way.
|
|
94
94
|
const INSTALL_POLL_MS = 1500;
|
|
95
|
-
|
|
95
|
+
// The window we allow with NO `running` signal from the server before giving up.
|
|
96
|
+
// The deadline is pushed forward on every `running` poll (see pollForInstall), so
|
|
97
|
+
// a genuinely-progressing install is never cut off — this only bounds a hung/lost
|
|
98
|
+
// installer. Generous vs the old fixed 90 s that cut off fresh-machine downloads.
|
|
99
|
+
const INSTALL_POLL_MAX_MS = 180000;
|
|
96
100
|
|
|
97
101
|
// DDR-166 T0c/T0d — one component drives both the "install then sign in"
|
|
98
102
|
// chain (mode='install') and the "sign in only" case (mode='signin'), since
|
|
@@ -144,13 +148,23 @@ function SetupAction({ mode, refresh }) {
|
|
|
144
148
|
if (Date.now() > timerRef.current.deadline) {
|
|
145
149
|
stopPoll();
|
|
146
150
|
setPhase('error');
|
|
147
|
-
setError('Install
|
|
151
|
+
setError('Install is taking longer than expected — it may still finish in the background; you can also run the command below yourself, then Re-check.');
|
|
148
152
|
return;
|
|
149
153
|
}
|
|
150
154
|
const state = await fetch('/_api/claude/install-status')
|
|
151
155
|
.then((r) => r.json())
|
|
152
156
|
.catch(() => null);
|
|
153
|
-
|
|
157
|
+
// The installer downloads a ~200 MB binary; on a fresh/slow machine that
|
|
158
|
+
// routinely exceeds a fixed 90 s wall clock (RCA G6 — the "Install timed
|
|
159
|
+
// out" the user hit while the fire-and-forget install was still running).
|
|
160
|
+
// While the server reports it's actively installing, push the deadline
|
|
161
|
+
// forward so a slow-but-progressing download is never declared a failure;
|
|
162
|
+
// the cap only bites if the server stops reporting `running` (hung/lost).
|
|
163
|
+
if (state && state.phase === 'running') {
|
|
164
|
+
timerRef.current.deadline = Date.now() + INSTALL_POLL_MAX_MS;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!state || state.phase !== 'done') return; // transient — keep polling to the deadline
|
|
154
168
|
stopPoll();
|
|
155
169
|
if (!state.ok) {
|
|
156
170
|
setPhase('error');
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Collapsed consecutive-tool-call summary row (Task C1) — folds a RUN of 2+
|
|
2
|
+
// back-to-back tool-call parts into one "Ran N tools" line, expandable to the
|
|
3
|
+
// individual ChatToolCards. A single isolated tool call renders as-is (no
|
|
4
|
+
// wrapper) so the common case stays exactly as it was.
|
|
5
|
+
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pure grouping fn (exported for tests): `parts` (a message's raw content
|
|
10
|
+
* array, or the continuation tail's parts) → an ordered list of
|
|
11
|
+
* `{ type:'single', part }` | `{ type:'tool-group', parts:[...] }`. Only
|
|
12
|
+
* CONSECUTIVE tool-call parts fold into a group; anything else (text,
|
|
13
|
+
* reasoning) — or a lone tool-call with no neighbor — passes through single.
|
|
14
|
+
*/
|
|
15
|
+
export function groupToolCalls(parts) {
|
|
16
|
+
const list = Array.isArray(parts) ? parts : [];
|
|
17
|
+
const out = [];
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < list.length) {
|
|
20
|
+
const p = list[i];
|
|
21
|
+
if (p?.type === 'tool-call') {
|
|
22
|
+
const run = [];
|
|
23
|
+
let j = i;
|
|
24
|
+
while (j < list.length && list[j]?.type === 'tool-call') {
|
|
25
|
+
run.push(list[j]);
|
|
26
|
+
j++;
|
|
27
|
+
}
|
|
28
|
+
out.push(run.length > 1 ? { type: 'tool-group', parts: run } : { type: 'single', part: run[0] });
|
|
29
|
+
i = j;
|
|
30
|
+
} else {
|
|
31
|
+
out.push({ type: 'single', part: p });
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "Ran N × Write file" when every entry in the run shares one title, else a
|
|
39
|
+
* short "Ran N tools — A, B, C…" listing the distinct titles seen. */
|
|
40
|
+
export function summarizeGroup(parts) {
|
|
41
|
+
const names = parts.map((p) => p.toolName || 'tool');
|
|
42
|
+
const uniq = [...new Set(names)];
|
|
43
|
+
if (uniq.length === 1) return `Ran ${parts.length} × ${uniq[0]}`;
|
|
44
|
+
const shown = uniq.slice(0, 3).join(', ');
|
|
45
|
+
return `Ran ${parts.length} tools — ${shown}${uniq.length > 3 ? '…' : ''}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function ToolGroup({ parts, ToolCard, forceOpen = false, verbose = false }) {
|
|
49
|
+
const [open, setOpen] = useState(forceOpen);
|
|
50
|
+
const allDone = parts.every((p) => p.result !== undefined);
|
|
51
|
+
const anyError = parts.some((p) => p.isError);
|
|
52
|
+
return (
|
|
53
|
+
<details
|
|
54
|
+
className="chat-toolgroup"
|
|
55
|
+
open={forceOpen || open}
|
|
56
|
+
onToggle={(e) => setOpen(e.currentTarget.open)}
|
|
57
|
+
data-testid="chat-tool-group"
|
|
58
|
+
>
|
|
59
|
+
<summary className="chat-toolgroup-sum">
|
|
60
|
+
<span className={`chat-tool-dot ${allDone ? 'chat-tool-dot--done' : 'chat-tool-dot--run'}`} />
|
|
61
|
+
<span>{summarizeGroup(parts)}</span>
|
|
62
|
+
{anyError ? <span className="del"> · error</span> : null}
|
|
63
|
+
</summary>
|
|
64
|
+
<div className="chat-toolgroup-body">
|
|
65
|
+
{parts.map((p, i) => (
|
|
66
|
+
<ToolCard
|
|
67
|
+
key={p.toolCallId || i}
|
|
68
|
+
toolName={p.toolName}
|
|
69
|
+
args={p.args}
|
|
70
|
+
result={p.result}
|
|
71
|
+
isError={p.isError}
|
|
72
|
+
verbose={verbose}
|
|
73
|
+
flat
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</details>
|
|
78
|
+
);
|
|
79
|
+
}
|