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>
198 lines
6.4 KiB
Dart
198 lines
6.4 KiB
Dart
/// The base template every conversation turn renders through (T-173).
|
|
///
|
|
/// One primitive with three [ConversationCardVariant]s (the stripe, bordered,
|
|
/// and bare looks the view used to hand-roll), plus chrome wired in once for
|
|
/// all message types: a hover-revealed copy button, an always-visible
|
|
/// collapse/expand caret for collapsible turns, and an extensible
|
|
/// [MessageAction] list. Decoupled from `ConversationItem` — the view maps
|
|
/// each item to (variant, accent, label, body, copyText, actions), so future
|
|
/// typed cards (T-168) reuse this chrome with a different body.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:clide/kernel/kernel.dart';
|
|
import 'package:clide/widgets/widgets.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
/// An extensible per-message affordance shown in the card's action bar.
|
|
/// Copy is provided by the card from `copyText`; callers add more (e.g.
|
|
/// fork-from-here, retry) without touching the template.
|
|
class MessageAction {
|
|
const MessageAction({required this.label, required this.onInvoke});
|
|
final String label;
|
|
final VoidCallback onInvoke;
|
|
}
|
|
|
|
enum ConversationCardVariant { stripe, bordered, bare }
|
|
|
|
class ConversationCard extends StatefulWidget {
|
|
const ConversationCard({
|
|
super.key,
|
|
this.variant = ConversationCardVariant.stripe,
|
|
required this.accent,
|
|
required this.label,
|
|
required this.body,
|
|
this.copyText,
|
|
this.actions = const [],
|
|
this.collapsible = false,
|
|
this.collapsedByDefault = false,
|
|
this.collapsedSummary,
|
|
this.borderColor,
|
|
});
|
|
|
|
final ConversationCardVariant variant;
|
|
final Color accent;
|
|
final String label;
|
|
final Widget body;
|
|
|
|
/// Raw text the copy action yields; no copy button when null.
|
|
final String? copyText;
|
|
|
|
/// Extra actions appended after copy.
|
|
final List<MessageAction> actions;
|
|
|
|
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;
|
|
|
|
@override
|
|
State<ConversationCard> createState() => _ConversationCardState();
|
|
}
|
|
|
|
class _ConversationCardState extends State<ConversationCard> {
|
|
bool _hover = false;
|
|
late bool _collapsed = widget.collapsible && widget.collapsedByDefault;
|
|
|
|
void _copy() {
|
|
final text = widget.copyText;
|
|
if (text != null) unawaited(ClideKernel.of(context).clipboard.writePlain(text));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideTheme.of(context).surface;
|
|
final content = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_header(tokens),
|
|
if (!_collapsed) ...[const SizedBox(height: 4), widget.body],
|
|
],
|
|
);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 14),
|
|
child: MouseRegion(
|
|
onEnter: (_) => setState(() => _hover = true),
|
|
onExit: (_) => setState(() => _hover = false),
|
|
child: _frame(tokens, content),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _frame(SurfaceTokens tokens, Widget content) {
|
|
switch (widget.variant) {
|
|
case ConversationCardVariant.stripe:
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: ColoredBox(
|
|
color: tokens.globalBackground,
|
|
child: IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Container(width: 3, color: widget.accent),
|
|
Expanded(
|
|
child: Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), child: content),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
case ConversationCardVariant.bordered:
|
|
return Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: tokens.globalBackground,
|
|
border: Border.all(color: widget.borderColor ?? tokens.panelBorder),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: content,
|
|
);
|
|
case ConversationCardVariant.bare:
|
|
return content;
|
|
}
|
|
}
|
|
|
|
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),
|
|
// 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),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _caret(SurfaceTokens tokens) {
|
|
return _tap(
|
|
label: _collapsed ? 'Expand' : 'Collapse',
|
|
onTap: () => setState(() => _collapsed = !_collapsed),
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: ClideIcon(
|
|
_collapsed ? PhosphorIcons.caretRight : PhosphorIcons.caretDown,
|
|
size: 12,
|
|
color: tokens.globalTextMuted,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _actions(SurfaceTokens tokens) {
|
|
Widget btn(String label, VoidCallback onTap) => _tap(
|
|
label: label,
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 10),
|
|
child: ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
|
),
|
|
);
|
|
return [
|
|
if (widget.copyText != null) btn('copy', _copy),
|
|
for (final a in widget.actions) btn(a.label, a.onInvoke),
|
|
];
|
|
}
|
|
|
|
Widget _tap({required String label, required VoidCallback onTap, required Widget child}) {
|
|
return Semantics(
|
|
button: true,
|
|
label: label,
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: MouseRegion(cursor: SystemMouseCursors.click, child: child),
|
|
),
|
|
);
|
|
}
|
|
}
|