flesh out the Claude prompt UX: options, stepper, collapse
Builds on the in-composer prompt surface (D-78): - Permission prompts (T-175): Allow / Allow-and-don't-ask-again / Deny. "Don't ask again" appears only when the request carries a permission_suggestion and echoes it back as updatedPermissions. An optional note rides Deny as the message, or Allow as a follow-up user message (the protocol has no allow-with-message). - AskUserQuestion picker (T-176): a single question renders bare; 2-4 questions step one at a time (nav shows "N · Header", ✓ when answered) then a review/confirm screen. Each question offers an "Other" free-text choice and a per-choice note; multi-select joins labels. A "chat instead" escape denies the prompt so the user can type freely. On submit the answer is echoed into the log, since the card is ephemeral. - Collapsed tool cards (T-177): multi-line tool_use / tool_result start collapsed behind a one-line summary; one-line output renders inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ class ConversationCard extends StatefulWidget {
|
||||
this.actions = const [],
|
||||
this.collapsible = false,
|
||||
this.collapsedByDefault = false,
|
||||
this.collapsedSummary,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
@@ -54,6 +55,11 @@ class ConversationCard extends StatefulWidget {
|
||||
final bool collapsible;
|
||||
final bool collapsedByDefault;
|
||||
|
||||
/// One-line gist shown next to the label while collapsed (e.g. the tool's
|
||||
/// key arg, or a result's first line), so a collapsed card still says what
|
||||
/// it holds. Null → just the label.
|
||||
final String? collapsedSummary;
|
||||
|
||||
/// Border colour for the bordered variant (e.g. error red); defaults to the
|
||||
/// panel border.
|
||||
final Color? borderColor;
|
||||
@@ -127,11 +133,20 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
}
|
||||
|
||||
Widget _header(SurfaceTokens tokens) {
|
||||
final summary = widget.collapsedSummary;
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.collapsible) _caret(tokens),
|
||||
ClideText(widget.label, fontSize: clideFontSmall, color: widget.accent, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
// While collapsed, show a one-line gist next to the label so the card
|
||||
// still says what it holds.
|
||||
if (_collapsed && summary != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
// Hover-revealed actions. (Always-reachable keyboard a11y for these is
|
||||
// a follow-up detail; the collapse caret above is always visible.)
|
||||
if (_hover) ..._actions(tokens),
|
||||
|
||||
@@ -149,26 +149,34 @@ class _ConversationTurn extends StatelessWidget {
|
||||
|
||||
Widget _toolUse(AssistantToolUse t) {
|
||||
final pretty = const JsonEncoder.withIndent(' ').convert(t.input);
|
||||
// Collapse only the bulky multi-line form; a trivial one-liner just shows.
|
||||
final multiline = pretty.contains('\n');
|
||||
return ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: tokens.globalFocus,
|
||||
label: t.name,
|
||||
copyText: pretty,
|
||||
collapsible: true,
|
||||
collapsible: multiline,
|
||||
collapsedByDefault: multiline,
|
||||
collapsedSummary: multiline ? _toolUseSummary(t) : null,
|
||||
body: ClideCodeBlock(source: pretty, language: 'json'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolResult(ToolResultMessage t) {
|
||||
final accent = t.isError ? tokens.statusError : tokens.globalTextMuted;
|
||||
// A one-line result is all chrome to collapse — show it inline. Only fold
|
||||
// away multi-line output, behind a summary of its first line.
|
||||
final multiline = t.content.contains('\n');
|
||||
return ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: accent,
|
||||
borderColor: t.isError ? tokens.statusError : tokens.panelBorder,
|
||||
label: t.isError ? 'error' : 'result',
|
||||
copyText: t.content,
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
collapsible: multiline,
|
||||
collapsedByDefault: multiline,
|
||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
||||
body: ClideText(
|
||||
t.content,
|
||||
fontSize: clideFontMeta,
|
||||
@@ -177,4 +185,18 @@ class _ConversationTurn extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A compact one-liner for a collapsed tool-use card: the most telling arg.
|
||||
String _toolUseSummary(AssistantToolUse t) {
|
||||
final input = t.input;
|
||||
final key =
|
||||
input['file_path'] ?? input['command'] ?? input['path'] ?? input['pattern'] ?? input['url'] ?? (input.values.isNotEmpty ? input.values.first : null);
|
||||
final s = key?.toString().replaceAll('\n', ' ') ?? '';
|
||||
return s.length > 80 ? '${s.substring(0, 80)}…' : s;
|
||||
}
|
||||
|
||||
String _firstLine(String content) {
|
||||
final line = content.split('\n').first.trim();
|
||||
return line.length > 80 ? '${line.substring(0, 80)}…' : line;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -91,6 +91,7 @@ class ToolPrompt {
|
||||
required this.input,
|
||||
this.description,
|
||||
this.toolUseId = '',
|
||||
this.permissionSuggestions = const [],
|
||||
});
|
||||
|
||||
/// The control_request `request_id` — the key passed to [StreamJsonSession.resolvePrompt].
|
||||
@@ -111,6 +112,11 @@ class ToolPrompt {
|
||||
/// The tool's proposed input — echoed back (possibly modified) on allow.
|
||||
final Map<String, dynamic> input;
|
||||
|
||||
/// Permission-rule suggestions from the request (e.g. a `setMode` /
|
||||
/// `localSettings` entry). Non-empty → an "allow & don't ask again" path is
|
||||
/// available; echo a chosen entry back as `updatedPermissions` (D-78).
|
||||
final List<dynamic> permissionSuggestions;
|
||||
|
||||
/// AskUserQuestion is answered through the same channel (D-78).
|
||||
bool get isQuestion => toolName == 'AskUserQuestion';
|
||||
}
|
||||
@@ -124,11 +130,22 @@ sealed class ToolDecision {
|
||||
/// Allow the tool. [updatedInput] is REQUIRED by the protocol — pass the
|
||||
/// request's input unchanged to allow as-is, or modified to alter the call.
|
||||
/// For AskUserQuestion, include the `answers` map (question text → label).
|
||||
///
|
||||
/// [updatedPermissions] echoes a permission suggestion back to skip future
|
||||
/// prompts ("don't ask again"). [followUpNote] is NOT part of the protocol —
|
||||
/// the protocol has no allow-with-message — so the session sends it as a
|
||||
/// separate user message right after allowing (D-78).
|
||||
final class AllowTool extends ToolDecision {
|
||||
const AllowTool(this.updatedInput);
|
||||
const AllowTool(this.updatedInput, {this.updatedPermissions, this.followUpNote});
|
||||
final Map<String, dynamic> updatedInput;
|
||||
final List<dynamic>? updatedPermissions;
|
||||
final String? followUpNote;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'behavior': 'allow', 'updatedInput': updatedInput};
|
||||
Map<String, dynamic> toJson() => {
|
||||
'behavior': 'allow',
|
||||
'updatedInput': updatedInput,
|
||||
if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
/// Deny the tool with a user-facing [message] (required by the protocol).
|
||||
@@ -218,6 +235,7 @@ class StreamJsonSession {
|
||||
description: request['description'] as String?,
|
||||
toolUseId: request['tool_use_id'] as String? ?? '',
|
||||
input: input,
|
||||
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
|
||||
));
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
return; // awaits resolvePrompt
|
||||
@@ -232,13 +250,27 @@ class StreamJsonSession {
|
||||
/// [ToolPrompt.promptId]. No-op if unknown or already resolved. Advances the
|
||||
/// queue so the next pending prompt (if any) surfaces.
|
||||
void resolvePrompt(String promptId, ToolDecision decision) {
|
||||
final before = _queue.length;
|
||||
_queue.removeWhere((p) => p.promptId == promptId);
|
||||
if (_queue.length == before) return; // unknown / already resolved
|
||||
final idx = _queue.indexWhere((p) => p.promptId == promptId);
|
||||
if (idx < 0) return; // unknown / already resolved
|
||||
final prompt = _queue.removeAt(idx);
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
||||
}));
|
||||
if (decision is AllowTool) {
|
||||
// The prompt card is ephemeral (it vanishes once resolved), so leave a
|
||||
// compact record of an answered question in the conversation log (D-78).
|
||||
if (prompt.isQuestion) {
|
||||
final answers = decision.updatedInput['answers'];
|
||||
if (answers is Map && answers.isNotEmpty) {
|
||||
final summary = answers.entries.map((e) => '${e.key} → ${e.value}').join('; ');
|
||||
_items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: '✓ answered: $summary'));
|
||||
}
|
||||
}
|
||||
// The protocol has no allow-with-message, so an allow note rides as a
|
||||
// follow-up user message right after the approval (D-78).
|
||||
if (decision.followUpNote?.trim().isNotEmpty ?? false) send(decision.followUpNote!.trim());
|
||||
}
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user