diff --git a/AGENTS.md b/AGENTS.md
index 6d5e334..a2dc0e8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,7 +81,7 @@ Status legend: ⬜ Todo · ✅ Done
| 14 | Menu | icon-trigger menu: groups, submenus, toggles; sheet on phones | ✅ |
| 15 | Attachments | images and files, type pill; built-in picker and web file drop; videos pending | ✅ |
| 16 | Preview | full-screen image viewer: zoom, paging | ✅ |
-| 17 | Tool | TBD | ⬜ |
+| 17 | Tool | pending, running, complete, error; morphing status mark; collapsible input/output blocks; parts render in a thread | ✅ |
| 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ |
| 19 | Confirmation | pending, approved, rejected; approve/reject buttons; parts render in a thread | ✅ |
| 20 | Error state | failure card + retry pill; failed assistant turns render it automatically | ✅ |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80833c4..ab88fc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.4.0 (unreleased)
+
+- Adds FlowTool, the tool-call card: one mark morphs with the call's status (a still asterisk while staged, turning while it runs, a check or an error glyph once settled), the host's title and primary argument sit on the row, and the input and output render as code blocks behind a disclosure, with a failure readable without a tap. FlowToolPart renders it in a thread, keyed by the provider's call id and copying through FlowThread.onCodeCopy; FlowToolStyle joins the component styles with a FlowTheme.toolStyle default. Metrics are provisional pending a design frame.
+
## 0.3.0
- Adds FlowToast and showFlowToast, a floating notice that stacks as a deck, dismisses itself after four seconds and pauses under the pointer.
diff --git a/CLAUDE.md b/CLAUDE.md
index 82753fd..19940f1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -83,7 +83,7 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc
| 14 | Menu | icon-trigger menu: groups, submenus, toggles; sheet on phones | ✅ |
| 15 | Attachments | images and files, type pill; built-in picker and web file drop; videos pending | ✅ |
| 16 | Preview | full-screen image viewer: zoom, paging | ✅ |
-| 17 | Tool | TBD | ⬜ |
+| 17 | Tool | pending, running, complete, error; morphing status mark; collapsible input/output blocks; parts render in a thread | ✅ |
| 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ |
| 19 | Confirmation | pending, approved, rejected; approve/reject buttons; parts render in a thread | ✅ |
| 20 | Error state | failure card + retry pill; failed assistant turns render it automatically | ✅ |
diff --git a/README.md b/README.md
index 367983a..017e687 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@
| [`FlowErrorState`](https://flowui.stac.dev/components/error-state) | Failure card with a host-written message and retry pill — failed turns render it automatically |
| [`FlowConfirmation`](https://flowui.stac.dev/components/confirmation) | Approval card — an asterisk-marked request with approve and reject buttons that settles into the outcome; confirmation parts render it in a thread |
| [`FlowToast`](https://flowui.stac.dev/components/toast) | Floating notice: a glyph, one wrapping line and a cross on a frosted card; `showFlowToast` floats it in the nearest Overlay, stacked and auto-dismissing, with a handle to dismiss it early |
+| [`FlowTool`](https://flowui.stac.dev/components/tool) | Tool-call card: one status mark that settles from a turning asterisk into a check or an error glyph, the call's title and primary argument, and its input and output behind a disclosure; tool parts render it in a thread |
| [`FlowMessageActions`](https://flowui.stac.dev/components/message-actions) | Copy / regenerate / edit / feedback row under a message |
| [`FlowComposer`](https://flowui.stac.dev/components/composer) | Multiline input with send/stop, attachments strip, the platform's file dialog (`showFlowAttachmentPicker` from your own menu, or a built-in attach button), image paste and card-scoped drop (web), and leading/trailing action slots |
| [`FlowMenu`](https://flowui.stac.dev/components/menu) | Icon-triggered menu with groups, submenus, and toggles — anchored card on desktop, bottom sheet on phones |
diff --git a/docs/public/_redirects b/docs/public/_redirects
index a23f6ee..a29f28a 100644
--- a/docs/public/_redirects
+++ b/docs/public/_redirects
@@ -28,6 +28,7 @@
/playground/error-state /playground/ 200
/playground/confirmation /playground/ 200
/playground/toast /playground/ 200
+/playground/tool /playground/ 200
/playground/add-to-chat /playground/ 200
/playground/pill /playground/ 200
/playground/attachments /playground/ 200
diff --git a/docs/src/content/docs/components/message-thread.mdx b/docs/src/content/docs/components/message-thread.mdx
index fe5480d..39428b6 100644
--- a/docs/src/content/docs/components/message-thread.mdx
+++ b/docs/src/content/docs/components/message-thread.mdx
@@ -97,8 +97,8 @@ FlowMessage(
Message content is typed parts, not strings: a sealed `FlowMessagePart`
with `FlowTextPart`, `FlowAttachmentPart`, `FlowImagePart`, and
-`FlowCustomPart` subtypes (plus `FlowCodePart`, `FlowErrorPart` and
-`FlowConfirmationPart`).
+`FlowCustomPart` subtypes (plus `FlowCodePart`, `FlowErrorPart`,
+`FlowConfirmationPart` and `FlowToolPart`).
`FlowAttachmentPart` renders sent files — lifted above a user bubble as
image cards, tiles elsewhere —
while `FlowImagePart` is the large-format picture, an AI-generated image
diff --git a/docs/src/content/docs/components/tool.mdx b/docs/src/content/docs/components/tool.mdx
new file mode 100644
index 0000000..be34c55
--- /dev/null
+++ b/docs/src/content/docs/components/tool.mdx
@@ -0,0 +1,225 @@
+---
+title: Tool
+description: "The tool-call card: one mark for the call's status, its title and primary argument on a row, and the input and output behind a disclosure."
+sidebar:
+ order: 19
+---
+
+import FlowDemo from '../../../components/FlowDemo.astro';
+
+`FlowTool` is the tool-call card: a record of one call the runtime made,
+with a single mark for where it stands, the host's title and the call's
+primary argument on the header row, and the raw input and output as code
+blocks behind a disclosure. It renders state and never advances on its
+own; the host re-renders the part as its provider reports each step. The
+package ships no strings, so the title, the detail chip, the error
+message and the section labels are all host-written.
+
+Status is the mark, never words: a still asterisk while the call is
+staged, the thinking line's turning asterisk beside a shimmering title
+while it runs, a check once it completes, the error glyph when it fails.
+That leaves no status vocabulary to localize, and keeps the card quiet in
+a turn that made five calls. The chrome is the code block's flat one
+rather than the confirmation's raised card: a tool call is a record of
+the runtime's work, where the confirmation is the host's gate that asks
+for an answer. A failure reads without a tap (the message hangs under the
+header, outside the disclosure), and every card lands collapsed unless
+told otherwise.
+
+## Live run
+
+A call replayed: staged while its arguments stream in, then executing,
+then settled with its output. Each step is a re-render with the next
+status; the card animates the mark and reveals nothing on its own.
+
+
+
+```dart title="Three re-renders"
+// 1. Staged: the arguments still arriving, the input block streaming.
+FlowTool(
+ name: 'search_docs',
+ title: 'Searching the docs',
+ status: FlowToolStatus.pending,
+ input: '{"query": "draft pers',
+)
+
+// 2. Executing: the asterisk turns and the title shimmers.
+FlowTool(
+ name: 'search_docs',
+ title: 'Searching the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.running,
+ input: '{"query": "draft persistence"}',
+)
+
+// 3. Settled: the check lands and the output joins the disclosure.
+FlowTool(
+ name: 'search_docs',
+ title: 'Searched the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.complete,
+ input: '{"query": "draft persistence"}',
+ output: '{"count": 2, "best": "/components/composer#drafts"}',
+ inputLabel: 'Input',
+ outputLabel: 'Output',
+)
+```
+
+## Complete
+
+The full anatomy, landed open: the settled check, the title, the primary
+argument as a chip, then the input and output as code blocks under the
+host's section labels. Copy on either block hands back a `FlowCodePart`
+carrying that block's text, the same contract as any code block, so one
+clipboard routine serves them all. `initiallyExpanded` is read once, when
+the card mounts; the disclosure is the user's from then on, and
+`onExpandedChanged` reports each toggle.
+
+
+
+```dart title="A settled call, open"
+FlowTool(
+ name: 'search_docs',
+ title: 'Searched the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.complete,
+ input: '{"query": "draft persistence"}',
+ output: '{"count": 2, "best": "/components/composer#drafts"}',
+ inputLabel: 'Input',
+ outputLabel: 'Output',
+ initiallyExpanded: true,
+ codeCopyTooltip: 'Copy',
+ copiedCodePart: copied,
+ onCodeCopy: (part) => copy(part),
+)
+```
+
+## Running
+
+Executing: the thinking line's asterisk turns and breathes, and the
+title sweeps from the muted ink to the full one. Pending looks the same
+but still, for a call staged and waiting (its arguments arriving, or an
+approval outstanding; a call that needs one is a `FlowConfirmationPart`
+beside it, not a tool state). Reduced motion renders both static.
+
+
+
+```dart title="Nothing to localize"
+FlowTool(
+ name: 'search_docs',
+ title: 'Searching the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.running,
+ input: '{"query": "draft persistence"}',
+ inputLabel: 'Input',
+)
+```
+
+## Error
+
+A failed call carries the error glyph, and the host's message sits under
+the header where it reads without a tap. The input stays behind the
+disclosure for whoever wants to see what was asked; the message is
+announced as a live region, since failures arrive unprompted.
+
+
+
+```dart title="The failure, readable at a glance"
+FlowTool(
+ name: 'fetch_weather',
+ title: 'Fetching the forecast',
+ detail: 'Berlin',
+ status: FlowToolStatus.error,
+ input: '{"city": "Berlin", "units": "metric"}',
+ errorMessage: 'The weather service timed out after 10 seconds.',
+ inputLabel: 'Input',
+)
+```
+
+## In a thread
+
+A `FlowToolPart` in any turn becomes this card. The section labels are
+thread-level, since they are the same words on every card; copy goes
+through `FlowThread.onCodeCopy` like every code block's; and the part's
+`id` (the provider's call id) keys the card, so its disclosure survives
+the turn's parts changing shape as the reply streams on. Keep the
+message's own status `complete` while a tool is pending or running: the
+wait belongs to the part, and a `pending` message renders the thinking
+indicator instead of its parts.
+
+
+
+```dart title="The host contract"
+FlowThread(
+ messages: messages,
+ toolInputLabel: 'Input',
+ toolOutputLabel: 'Output',
+ codeCopyTooltip: 'Copy',
+ copiedCodePart: copied,
+ onCodeCopy: (part) => copy(part),
+)
+
+FlowMessageData(
+ id: 'a1',
+ role: FlowMessageRole.assistant,
+ parts: [
+ FlowTextPart('Let me check the docs and the forecast.'),
+ FlowToolPart(
+ id: 'call_1',
+ name: 'search_docs',
+ title: 'Searched the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.complete,
+ input: '{"query": "draft persistence"}',
+ output: '{"count": 2, "best": "/components/composer#drafts"}',
+ ),
+ FlowToolPart(
+ id: 'call_2',
+ name: 'fetch_weather',
+ title: 'Fetching the forecast',
+ detail: 'Berlin',
+ status: FlowToolStatus.error,
+ input: '{"city": "Berlin", "units": "metric"}',
+ errorMessage: 'The weather service timed out after 10 seconds.',
+ ),
+ FlowTextPart('Drafts persist per thread. I could not reach the weather service.'),
+ ],
+)
+```
+
+## Restyling
+
+`FlowToolStyle` carries the card's overrides. Install one on
+`FlowTheme.toolStyle` for every card, or pass `style:` to one widget; a
+widget's own style wins field by field, and nulls fall through to the
+tokens. The four accents color the mark, one per status, and never the
+words: the title reads in the ink ramp, so an accent that falls short of
+WCAG AA for text still signals beside it. The nested code blocks follow
+`FlowTheme.codeBlockStyle`, not this style.
+
+```dart title="A branded running mark"
+FlowTool(
+ name: 'search_docs',
+ title: 'Searching the docs',
+ status: FlowToolStatus.running,
+ style: const FlowToolStyle(runningColor: Color(0xFFB65C33)),
+)
+```
+
+Beyond the style class, `padding:` (around the body's blocks) and
+`borderRadius:` override the card's own metrics, the per-component
+convention. The metrics themselves are provisional pending a design
+frame.
+
+## Key API
+
+| Member | What it does |
+|---|---|
+| `name` / `title` / `detail` | The tool's identifier (shown in the code face without a title), the host's label, and the primary argument as a chip |
+| `status` | `FlowToolStatus.pending` (a still asterisk), `running` (turning, the title shimmering), `complete` (a check), `error` (the error glyph) |
+| `input` / `inputLanguage` / `output` / `outputLanguage` | Raw text for the two blocks; `inputLanguage` defaults to `json`, a null `outputLanguage` renders plain |
+| `errorMessage` | Host-written failure, under the header and outside the disclosure; a live region |
+| `initiallyExpanded` / `onExpandedChanged` | The disclosure: read once on mount, then the user's; each toggle reported |
+| `onCodeCopy` / `copiedCodePart` / `codeCopyTooltip` | The blocks' copy contract, shared with every code block |
+| `inputLabel` / `outputLabel` | Host-localized block headers; null falls back to the language id |
+| `style` | `FlowToolStyle` overrides, merged over `FlowTheme.toolStyle` |
diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md
index fe5ebbb..7b7a0a1 100644
--- a/docs/src/content/docs/roadmap.md
+++ b/docs/src/content/docs/roadmap.md
@@ -36,7 +36,7 @@ elements and the remaining AI states are on the way.
| Menu | Shipped |
| Attachments | Shipped |
| Attachment preview | Shipped |
-| Tool | Planned |
+| Tool | Shipped |
| Suggestions | Shipped |
| Confirmation | Shipped |
| Error state | Shipped |
diff --git a/docs/src/content/docs/theming.mdx b/docs/src/content/docs/theming.mdx
index d346e7d..a089cfb 100644
--- a/docs/src/content/docs/theming.mdx
+++ b/docs/src/content/docs/theming.mdx
@@ -138,7 +138,8 @@ data bag of optional overrides (`FlowMenuStyle`, `FlowMarkdownStyle`,
`FlowComposerStyle`, `FlowMessageStyle`, `FlowCodeBlockStyle`,
`FlowConfirmationStyle`, `FlowErrorStateStyle`,
`FlowMessageActionsStyle`, `FlowPillStyle`,
-`FlowSuggestionStyle`, `FlowToastStyle`), and the theme can carry an app-wide default for
+`FlowSuggestionStyle`, `FlowToastStyle`, `FlowToolStyle`), and the theme can
+carry an app-wide default for
each:
```dart title="Restyle every instance once"
diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart
index a30a86c..b2c1c72 100644
--- a/lib/flow_ui.dart
+++ b/lib/flow_ui.dart
@@ -58,3 +58,5 @@ export 'src/styles/flow_thread_list_style.dart';
export 'src/widgets/flow_toast.dart';
export 'src/styles/flow_toast_style.dart';
export 'src/utils/flow_toast_layer.dart' show showFlowToast, FlowToastHandle;
+export 'src/widgets/flow_tool.dart';
+export 'src/styles/flow_tool_style.dart';
diff --git a/lib/src/models/flow_message_part.dart b/lib/src/models/flow_message_part.dart
index 1d6288f..cc0014b 100644
--- a/lib/src/models/flow_message_part.dart
+++ b/lib/src/models/flow_message_part.dart
@@ -5,9 +5,9 @@ import 'flow_attachment.dart';
/// One piece of content inside a `FlowMessageData`.
///
-/// Sealed so renderers can switch exhaustively. More part types (tool)
-/// arrive alongside their components; [FlowCustomPart] is how hosts inject
-/// arbitrary content today.
+/// Sealed so renderers can switch exhaustively. Each part type arrives
+/// alongside its component; [FlowCustomPart] is how hosts inject anything
+/// else.
@immutable
sealed class FlowMessagePart {
const FlowMessagePart();
@@ -146,6 +146,94 @@ class FlowConfirmationPart extends FlowMessagePart {
final FlowConfirmationStatus status;
}
+/// Where a tool call stands.
+///
+/// The host owns every transition and re-renders the part; the card never
+/// advances on its own. [pending] is staged — the arguments still
+/// arriving, or an approval outstanding (a call that needs one is a
+/// [FlowConfirmationPart] beside it, not a tool state); [running] is
+/// executing; [complete] and [error] are settled. A cancelled call is the
+/// host's to map: [error] with a message, or [complete].
+enum FlowToolStatus { pending, running, complete, error }
+
+/// One tool invocation, rendered by a `FlowTool` card.
+///
+/// A record of the runtime's work, composed by the host from what its
+/// provider reports: the tool's [name], the call's [status], the raw
+/// [input] and [output] as text, and a [title] and [detail] in the host's
+/// words. The card carries status as a mark, never as words, so nothing
+/// here needs localizing beyond what the host writes itself. The section
+/// labels over the input and output are thread-level
+/// (`FlowThread.toolInputLabel` and friends), since they are the same
+/// words on every card.
+class FlowToolPart extends FlowMessagePart {
+ const FlowToolPart({
+ required this.name,
+ this.id,
+ this.title,
+ this.detail,
+ this.input,
+ this.inputLanguage = 'json',
+ this.output,
+ this.outputLanguage,
+ this.status = FlowToolStatus.pending,
+ this.errorMessage,
+ this.semanticLabel,
+ });
+
+ /// The tool's identifier as the model called it, e.g. 'search_docs'.
+ /// Shown in the code face when there is no [title].
+ final String name;
+
+ /// The provider's stable id for this call. Keys the card in a thread so
+ /// its disclosure survives the turn's parts changing shape — and the
+ /// thread remounting — so pass one where the provider has one. Unique
+ /// within the conversation.
+ final String? id;
+
+ /// Host-written label, e.g. 'Searching the docs'. The host re-renders
+ /// with a settled tense — 'Searched the docs' — when the call lands.
+ /// Null shows [name].
+ final String? title;
+
+ /// A one-line summary of the input, usually its primary argument (the
+ /// query, the path), shown as a chip beside the title so a collapsed
+ /// card still says what the call was about.
+ final String? detail;
+
+ /// The arguments as raw text, usually JSON — partial while [pending],
+ /// rendered streaming. Null renders no input block.
+ final String? input;
+
+ /// `FlowCodeLanguage` id or alias for [input]. Defaults to `'json'`;
+ /// `'plain'` for no highlighting.
+ final String inputLanguage;
+
+ /// The result as raw text. Null renders no output block.
+ final String? output;
+
+ /// `FlowCodeLanguage` id or alias for [output]. Null renders plain.
+ final String? outputLanguage;
+
+ /// Drives the header's mark and the blocks' streaming treatment.
+ ///
+ /// Keep the message's own status `complete` while a tool is pending or
+ /// running — the wait belongs to this part, and a `pending` message
+ /// renders the thinking indicator instead of its parts.
+ final FlowToolStatus status;
+
+ /// Why the call failed, host-written and sentence-case, to pair with
+ /// [FlowToolStatus.error]. Rendered under the header whenever set —
+ /// outside the disclosure, so a failure reads without a tap — and
+ /// announced as a live region.
+ final String? errorMessage;
+
+ /// Read to assistive tech in place of the title, e.g. 'Searched the
+ /// docs, complete'. The mark carries status with no words, so this is
+ /// the only spoken form of it; null reads [title], then [name].
+ final String? semanticLabel;
+}
+
/// Host-defined content, rendered through a `FlowCustomPartBuilder`.
class FlowCustomPart extends FlowMessagePart {
const FlowCustomPart({required this.type, this.data});
diff --git a/lib/src/styles/flow_tool_style.dart b/lib/src/styles/flow_tool_style.dart
new file mode 100644
index 0000000..6e3eaaf
--- /dev/null
+++ b/lib/src/styles/flow_tool_style.dart
@@ -0,0 +1,177 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowTool]'s look, on top of the theme tokens.
+///
+/// Every field is optional; null falls back to the token-derived default
+/// noted on the field. Install one on [FlowTheme.toolStyle] to restyle
+/// every tool card — tool parts in a thread included; a widget's own
+/// `style` wins field by field:
+///
+/// ```dart
+/// FlowTool(
+/// name: 'search_docs',
+/// title: 'Searching the docs',
+/// status: FlowToolStatus.running,
+/// style: const FlowToolStyle(runningColor: Color(0xFFB65C33)),
+/// )
+/// ```
+///
+/// The four accents color the header's mark, one per status — the still
+/// and the turning asterisk, the settled check, the error glyph — never
+/// the words: the title reads in the ink ramp, so an accent that falls
+/// short of WCAG AA for text still signals beside it. The nested code
+/// blocks follow `FlowTheme.codeBlockStyle`, not this style.
+@immutable
+class FlowToolStyle {
+ const FlowToolStyle({
+ this.backgroundColor,
+ this.borderColor,
+ this.hoverBorderColor,
+ this.hoverColor,
+ this.pendingColor,
+ this.runningColor,
+ this.completeColor,
+ this.errorColor,
+ this.titleStyle,
+ this.nameStyle,
+ this.detailStyle,
+ this.detailChipColor,
+ this.dividerColor,
+ this.errorMessageStyle,
+ });
+
+ /// The card's fill. Defaults to `surfaceContainerLowest`.
+ final Color? backgroundColor;
+
+ /// The card's hairline. Defaults to `outline`.
+ final Color? borderColor;
+
+ /// The hairline while hovered. Defaults to `outlineVariant`; when only
+ /// [borderColor] is set, hover keeps that color instead of firming.
+ final Color? hoverBorderColor;
+
+ /// The header row's wash while hovered. Defaults to
+ /// `surfaceContainerLow`.
+ final Color? hoverColor;
+
+ /// The still asterisk while pending. Defaults to `onSurfaceMuted`.
+ final Color? pendingColor;
+
+ /// The turning asterisk while running. Defaults to `onSurfaceMuted`.
+ final Color? runningColor;
+
+ /// The settled check. Defaults to `success`.
+ final Color? completeColor;
+
+ /// The error glyph. Defaults to `error`.
+ final Color? errorColor;
+
+ /// Merged over the title's default `labelMedium` + state ink. Its
+ /// color only shows while static: the running shimmer draws with the
+ /// muted and full inks.
+ final TextStyle? titleStyle;
+
+ /// Merged over the name's default `codeInline` + state ink, for a card
+ /// with no title. Same shimmer caveat as [titleStyle].
+ final TextStyle? nameStyle;
+
+ /// Merged over the detail chip's default `codeInline` +
+ /// `onSurfaceVariant` style.
+ final TextStyle? detailStyle;
+
+ /// The detail chip's fill. Defaults to `surfaceContainer`.
+ final Color? detailChipColor;
+
+ /// The rule between the header and the open body. Defaults to
+ /// `outline`.
+ final Color? dividerColor;
+
+ /// Merged over the error message's default `bodyMedium` +
+ /// `onSurfaceVariant` style.
+ final TextStyle? errorMessageStyle;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowToolStyle merge(FlowToolStyle? other) {
+ if (other == null) return this;
+ return FlowToolStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ borderColor: other.borderColor ?? borderColor,
+ hoverBorderColor: other.hoverBorderColor ?? hoverBorderColor,
+ hoverColor: other.hoverColor ?? hoverColor,
+ pendingColor: other.pendingColor ?? pendingColor,
+ runningColor: other.runningColor ?? runningColor,
+ completeColor: other.completeColor ?? completeColor,
+ errorColor: other.errorColor ?? errorColor,
+ titleStyle: other.titleStyle ?? titleStyle,
+ nameStyle: other.nameStyle ?? nameStyle,
+ detailStyle: other.detailStyle ?? detailStyle,
+ detailChipColor: other.detailChipColor ?? detailChipColor,
+ dividerColor: other.dividerColor ?? dividerColor,
+ errorMessageStyle: other.errorMessageStyle ?? errorMessageStyle,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowToolStyle lerp(FlowToolStyle? other, double t) {
+ if (other == null) return this;
+ return FlowToolStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ borderColor: Color.lerp(borderColor, other.borderColor, t),
+ hoverBorderColor: Color.lerp(hoverBorderColor, other.hoverBorderColor, t),
+ hoverColor: Color.lerp(hoverColor, other.hoverColor, t),
+ pendingColor: Color.lerp(pendingColor, other.pendingColor, t),
+ runningColor: Color.lerp(runningColor, other.runningColor, t),
+ completeColor: Color.lerp(completeColor, other.completeColor, t),
+ errorColor: Color.lerp(errorColor, other.errorColor, t),
+ titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t),
+ nameStyle: TextStyle.lerp(nameStyle, other.nameStyle, t),
+ detailStyle: TextStyle.lerp(detailStyle, other.detailStyle, t),
+ detailChipColor: Color.lerp(detailChipColor, other.detailChipColor, t),
+ dividerColor: Color.lerp(dividerColor, other.dividerColor, t),
+ errorMessageStyle: TextStyle.lerp(
+ errorMessageStyle,
+ other.errorMessageStyle,
+ t,
+ ),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowToolStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.borderColor == borderColor &&
+ other.hoverBorderColor == hoverBorderColor &&
+ other.hoverColor == hoverColor &&
+ other.pendingColor == pendingColor &&
+ other.runningColor == runningColor &&
+ other.completeColor == completeColor &&
+ other.errorColor == errorColor &&
+ other.titleStyle == titleStyle &&
+ other.nameStyle == nameStyle &&
+ other.detailStyle == detailStyle &&
+ other.detailChipColor == detailChipColor &&
+ other.dividerColor == dividerColor &&
+ other.errorMessageStyle == errorMessageStyle;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ borderColor,
+ hoverBorderColor,
+ hoverColor,
+ pendingColor,
+ runningColor,
+ completeColor,
+ errorColor,
+ titleStyle,
+ nameStyle,
+ detailStyle,
+ detailChipColor,
+ dividerColor,
+ errorMessageStyle,
+ );
+}
diff --git a/lib/src/theme/flow_theme.dart b/lib/src/theme/flow_theme.dart
index 51f8238..2c2db4b 100644
--- a/lib/src/theme/flow_theme.dart
+++ b/lib/src/theme/flow_theme.dart
@@ -13,6 +13,7 @@ import '../styles/flow_pill_style.dart';
import '../styles/flow_suggestion_style.dart';
import '../styles/flow_thread_list_style.dart';
import '../styles/flow_toast_style.dart';
+import '../styles/flow_tool_style.dart';
import 'flow_colors.dart';
import 'flow_syntax_colors.dart';
import 'flow_typography.dart';
@@ -60,6 +61,7 @@ class FlowTheme extends ThemeExtension {
this.suggestionStyle,
this.threadListStyle,
this.toastStyle,
+ this.toolStyle,
this.chatViewStyle,
});
@@ -123,6 +125,10 @@ class FlowTheme extends ThemeExtension {
/// floats included.
final FlowToastStyle? toastStyle;
+ /// App-wide default for every `FlowTool` — tool parts in a thread
+ /// included.
+ final FlowToolStyle? toolStyle;
+
/// App-wide default for `FlowChatView.style` — the drop treatment's
/// gradient, glyph and label.
final FlowChatViewStyle? chatViewStyle;
@@ -144,6 +150,7 @@ class FlowTheme extends ThemeExtension {
FlowSuggestionStyle? suggestionStyle,
FlowThreadListStyle? threadListStyle,
FlowToastStyle? toastStyle,
+ FlowToolStyle? toolStyle,
FlowChatViewStyle? chatViewStyle,
}) {
return FlowTheme(
@@ -162,6 +169,7 @@ class FlowTheme extends ThemeExtension {
suggestionStyle: suggestionStyle ?? this.suggestionStyle,
threadListStyle: threadListStyle ?? this.threadListStyle,
toastStyle: toastStyle ?? this.toastStyle,
+ toolStyle: toolStyle ?? this.toolStyle,
chatViewStyle: chatViewStyle ?? this.chatViewStyle,
);
}
@@ -209,6 +217,9 @@ class FlowTheme extends ThemeExtension {
toastStyle: toastStyle == null
? other.toastStyle
: toastStyle!.lerp(other.toastStyle, t),
+ toolStyle: toolStyle == null
+ ? other.toolStyle
+ : toolStyle!.lerp(other.toolStyle, t),
chatViewStyle: chatViewStyle == null
? other.chatViewStyle
: chatViewStyle!.lerp(other.chatViewStyle, t),
diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart
index 628ccdd..028796b 100644
--- a/lib/src/widgets/flow_message.dart
+++ b/lib/src/widgets/flow_message.dart
@@ -16,6 +16,7 @@ import 'flow_error_state.dart';
import 'flow_markdown.dart';
import 'flow_streaming_text.dart';
import 'flow_thinking_indicator.dart';
+import 'flow_tool.dart';
/// Renders a [FlowCustomPart]; return null to skip it.
///
@@ -41,7 +42,9 @@ typedef FlowCustomPartBuilder =
/// card — the message's own [FlowErrorPart], or a default one when the host
/// supplies none; an error user bubble recolors to the error container.
/// A [FlowConfirmationPart] renders a [FlowConfirmation] card, its buttons
-/// reporting through [onConfirmationRespond].
+/// reporting through [onConfirmationRespond]; a [FlowToolPart] renders a
+/// [FlowTool] card, its blocks copying through [onCodeCopy] like any code
+/// part.
class FlowMessage extends StatelessWidget {
const FlowMessage(
this.message, {
@@ -62,6 +65,8 @@ class FlowMessage extends StatelessWidget {
this.rejectLabel,
this.approvedLabel,
this.rejectedLabel,
+ this.toolInputLabel,
+ this.toolOutputLabel,
this.leading,
this.footer,
this.maxBubbleWidthFraction = 0.75,
@@ -143,6 +148,12 @@ class FlowMessage extends StatelessWidget {
final String? approvedLabel;
final String? rejectedLabel;
+ /// Host-localized section labels over a tool card's input and output
+ /// blocks, e.g. 'Input' and 'Output'. Null lets each block fall back to
+ /// its language id, as a fence does.
+ final String? toolInputLabel;
+ final String? toolOutputLabel;
+
/// Slot beside the content, e.g. an avatar.
final Widget? leading;
@@ -376,13 +387,14 @@ class FlowMessage extends StatelessWidget {
child: Text(text, style: style, textAlign: TextAlign.center),
),
// System messages are centered notices; attachments, images,
- // code, failures and confirmations belong to user and
- // assistant turns.
+ // code, failures, confirmations and tool calls belong to
+ // user and assistant turns.
FlowAttachmentPart() ||
FlowImagePart() ||
FlowCodePart() ||
FlowErrorPart() ||
- FlowConfirmationPart() => const SizedBox.shrink(),
+ FlowConfirmationPart() ||
+ FlowToolPart() => const SizedBox.shrink(),
FlowCustomPart() =>
customPartBuilder?.call(context, message, part) ??
const SizedBox.shrink(),
@@ -641,16 +653,44 @@ class FlowMessage extends StatelessWidget {
? null
: () => onConfirmationRespond(confirmation, false),
),
+ // Bound whole for the same reason; the blocks' copy intent goes
+ // through [onCodeCopy] like any code part's.
+ FlowToolPart tool => FlowTool(
+ name: tool.name,
+ title: tool.title,
+ detail: tool.detail,
+ status: tool.status,
+ input: tool.input,
+ inputLanguage: tool.inputLanguage,
+ output: tool.output,
+ outputLanguage: tool.outputLanguage,
+ errorMessage: tool.errorMessage,
+ semanticLabel: tool.semanticLabel,
+ inputLabel: toolInputLabel,
+ outputLabel: toolOutputLabel,
+ onCodeCopy: onCodeCopy,
+ copiedCodePart: copiedCodePart,
+ codeCopyTooltip: codeCopyTooltip,
+ ),
FlowCustomPart() => customPartBuilder?.call(context, message, part),
};
if (child == null) continue;
+ // One wrapper type for every part, so the column matches unkeyed
+ // parts by position and a keyed one by its key — which must sit on
+ // the outermost child, where the column sees it. A tool card is
+ // keyed by its call id so its disclosure survives the turn's parts
+ // changing shape; a PageStorageKey rather than a ValueKey, since
+ // the thread remounts its list at the viewport flip and the card
+ // reads its disclosure back through PageStorage.
+ final id = part is FlowToolPart ? part.id : null;
children.add(
- children.isEmpty
- ? child
- : Padding(
- padding: const EdgeInsets.only(top: _partGap),
- child: child,
- ),
+ Padding(
+ key: id == null ? null : PageStorageKey(id),
+ padding: children.isEmpty
+ ? EdgeInsets.zero
+ : const EdgeInsets.only(top: _partGap),
+ child: child,
+ ),
);
}
diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart
index cdae696..47915d5 100644
--- a/lib/src/widgets/flow_thread.dart
+++ b/lib/src/widgets/flow_thread.dart
@@ -42,6 +42,8 @@ class FlowThread extends StatefulWidget {
this.rejectLabel,
this.approvedLabel,
this.rejectedLabel,
+ this.toolInputLabel,
+ this.toolOutputLabel,
this.controller,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag,
this.padding,
@@ -123,6 +125,13 @@ class FlowThread extends StatefulWidget {
final String? approvedLabel;
final String? rejectedLabel;
+ /// Host-localized section labels for the thread's tool cards — over
+ /// each card's input and output blocks, e.g. 'Input' and 'Output'.
+ /// Forwarded to each [FlowMessage]; null lets a block fall back to its
+ /// language id.
+ final String? toolInputLabel;
+ final String? toolOutputLabel;
+
/// Optional external scroll controller.
final ScrollController? controller;
@@ -347,6 +356,8 @@ class _FlowThreadState extends State {
rejectLabel: widget.rejectLabel,
approvedLabel: widget.approvedLabel,
rejectedLabel: widget.rejectedLabel,
+ toolInputLabel: widget.toolInputLabel,
+ toolOutputLabel: widget.toolOutputLabel,
charactersPerSecond: widget.charactersPerSecond,
thinkingLabel: widget.thinkingLabel,
footer: widget.messageFooter?.call(message),
diff --git a/lib/src/widgets/flow_tool.dart b/lib/src/widgets/flow_tool.dart
new file mode 100644
index 0000000..a4d2681
--- /dev/null
+++ b/lib/src/widgets/flow_tool.dart
@@ -0,0 +1,629 @@
+import 'package:flutter/foundation.dart' show listEquals;
+import 'package:material_ui/material_ui.dart';
+
+import '../models/flow_message_part.dart';
+import '../styles/flow_tool_style.dart';
+import '../theme/flow_colors.dart';
+import '../theme/flow_theme.dart';
+import 'flow_code_block.dart';
+import 'flow_shimmer_text.dart';
+import 'flow_thinking_indicator.dart';
+
+/// The tool-call card: one mark for the call's status, its title and
+/// primary argument on a row, and the raw input and output behind a
+/// disclosure.
+///
+/// ```dart
+/// FlowTool(
+/// name: 'search_docs',
+/// title: 'Searched the docs',
+/// detail: 'draft persistence',
+/// status: FlowToolStatus.complete,
+/// input: '{"query": "draft persistence"}',
+/// output: '{"count": 2}',
+/// inputLabel: 'Input',
+/// outputLabel: 'Output',
+/// )
+/// ```
+///
+/// In a thread this renders on its own: a `FlowToolPart` in any turn
+/// becomes this card, its blocks copying through `FlowThread.onCodeCopy`
+/// like any code part and the section labels thread-level. Standalone it
+/// serves hosts that show a call outside a conversation.
+///
+/// The widget renders state; the host owns every status transition and
+/// re-renders with the next one — the card never advances on its own.
+/// Status is the header's mark, never words: a still asterisk while
+/// [FlowToolStatus.pending], the thinking line's turning asterisk beside
+/// a shimmering title while [FlowToolStatus.running], a check once
+/// [FlowToolStatus.complete], the error glyph on [FlowToolStatus.error] —
+/// so there is no status vocabulary to localize. The disclosure is the
+/// one thing the card owns: collapsed unless [initiallyExpanded], toggled
+/// by the header, reported through [onExpandedChanged].
+///
+/// The chrome is the code block's flat one, not the confirmation's raised
+/// card: a tool call is a record of the runtime's work, where the
+/// confirmation is the host's gate that asks for an answer. A failure
+/// reads without a tap — [errorMessage] hangs under the header, outside
+/// the disclosure. The package ships no strings: the title, the detail,
+/// the message and the section labels are host-written. Metrics are
+/// provisional pending a design frame.
+class FlowTool extends StatefulWidget {
+ const FlowTool({
+ super.key,
+ required this.name,
+ this.title,
+ this.detail,
+ this.status = FlowToolStatus.pending,
+ this.input,
+ this.inputLanguage = 'json',
+ this.output,
+ this.outputLanguage,
+ this.errorMessage,
+ this.inputLabel,
+ this.outputLabel,
+ this.initiallyExpanded = false,
+ this.onExpandedChanged,
+ this.onCodeCopy,
+ this.copiedCodePart,
+ this.codeCopyTooltip,
+ this.semanticLabel,
+ this.padding,
+ this.borderRadius,
+ this.style,
+ });
+
+ /// The tool's identifier as the model called it, e.g. 'search_docs'.
+ /// Shown in the code face when there is no [title].
+ final String name;
+
+ /// Host-written label, e.g. 'Searching the docs' — re-rendered in a
+ /// settled tense when the call lands. Null shows [name].
+ final String? title;
+
+ /// A one-line summary of the input, usually its primary argument, shown
+ /// as a chip beside the title so the collapsed card still says what the
+ /// call was about.
+ final String? detail;
+
+ /// Drives the mark and the blocks' streaming treatment.
+ final FlowToolStatus status;
+
+ /// The arguments as raw text, usually JSON. Rendered streaming while
+ /// [status] is pending; null renders no input block.
+ final String? input;
+
+ /// `FlowCodeLanguage` id or alias for [input]; `'plain'` for none.
+ final String inputLanguage;
+
+ /// The result as raw text. Rendered streaming while [status] is
+ /// running; null renders no output block.
+ final String? output;
+
+ /// `FlowCodeLanguage` id or alias for [output]. Null renders plain.
+ final String? outputLanguage;
+
+ /// Why the call failed, host-written and sentence-case. Rendered under
+ /// the header whenever set, outside the disclosure, and announced as a
+ /// live region.
+ final String? errorMessage;
+
+ /// Host-localized header of the input block, e.g. 'Input'. Null falls
+ /// back to the block's language id, as a fence does.
+ final String? inputLabel;
+
+ /// Host-localized header of the output block, e.g. 'Output'. Null falls
+ /// back to the language id, and with neither the block keeps only its
+ /// copy affordance.
+ final String? outputLabel;
+
+ /// Whether the body starts open. Read once, when the card mounts —
+ /// changing it later does nothing, `ExpansionTile`'s contract; the
+ /// disclosure is the user's from then on.
+ final bool initiallyExpanded;
+
+ /// The disclosure toggled by the user, handed the new state. Not called
+ /// on mount or restore.
+ final ValueChanged? onExpandedChanged;
+
+ /// Copy intent from either block, handed a `FlowCodePart` carrying that
+ /// block's text — the same handler that serves code parts and fences,
+ /// so one host clipboard routine covers them all. Null hides both copy
+ /// affordances.
+ final ValueChanged? onCodeCopy;
+
+ /// The part whose block shows the copied check — pass back the instance
+ /// received from [onCodeCopy] for as long as the confirmation should
+ /// last; the host owns the timing.
+ final FlowCodePart? copiedCodePart;
+
+ /// Host-localized label for the blocks' copy affordance.
+ final String? codeCopyTooltip;
+
+ /// Read to assistive tech in place of the title and detail, e.g.
+ /// 'Searched the docs, complete'. The mark carries status with no
+ /// words, so this is the only spoken form of it; null reads [title] (or
+ /// [name]) and [detail].
+ final String? semanticLabel;
+
+ /// Around the body's blocks. Defaults to the design's 12.
+ final EdgeInsetsGeometry? padding;
+
+ /// The card's corner. Defaults to the design's 12.
+ final BorderRadius? borderRadius;
+
+ /// Per-instance restyling, merged over [FlowTheme.toolStyle]'s fields;
+ /// nulls fall through to the theme tokens.
+ final FlowToolStyle? style;
+
+ @override
+ State createState() => _FlowToolState();
+}
+
+class _FlowToolState extends State
+ with SingleTickerProviderStateMixin {
+ /// The card: the code block's 12px corner on the lowest wash, under the
+ /// faint hairline that firms while hovered.
+ static const BorderRadius _radius = BorderRadius.all(Radius.circular(12));
+
+ /// The header row: at least 36 tall — the code block header's height,
+ /// a minimum so large type grows it — inset 12 at the start, 10 at the
+ /// end where the chevron's own frame pads it, 6 above and below.
+ static const double _headerMinHeight = 36;
+ static const EdgeInsetsGeometry _headerPadding =
+ EdgeInsetsDirectional.fromSTEB(12, 6, 10, 6);
+
+ /// The mark: a 16px slot, the confirmation's asterisk size, a 6 gap
+ /// from the title — the error card's glyph rhythm — and the same 6
+ /// before the chip and the chevron.
+ static const double _markSize = 16;
+ static const double _gap = 6;
+
+ /// The chip: the markdown inline-code chip's 4px corner, padded 4/1.
+ static const BorderRadius _chipRadius = BorderRadius.all(Radius.circular(4));
+ static const EdgeInsetsGeometry _chipPadding = EdgeInsets.symmetric(
+ horizontal: 4,
+ vertical: 1,
+ );
+
+ /// The chevron: the menus' 18, making a half turn as the body opens.
+ static const double _chevronSize = 18;
+ static const double _chevronTurns = 0.5;
+
+ /// The error line, hung under the header and inset to its text.
+ static const EdgeInsetsGeometry _errorPadding =
+ EdgeInsetsDirectional.fromSTEB(12, 0, 12, 10);
+
+ /// The body: a rule above it, 12 around the blocks, 8 between them —
+ /// the message's part gap — and the blocks' corner a step under the
+ /// card's.
+ static const double _dividerThickness = 1;
+ static const EdgeInsetsGeometry _bodyPadding = EdgeInsets.all(12);
+ static const double _blockGap = 8;
+ static const BorderRadius _blockRadius = BorderRadius.all(Radius.circular(8));
+
+ /// The body's reveal and the chevron's turn share one clock — the
+ /// markdown's growth beat — and the mark crossfades at the image
+ /// fade's.
+ static const Duration _bodyDuration = Duration(milliseconds: 140);
+ static const Duration _markDuration = Duration(milliseconds: 180);
+
+ late final AnimationController _controller;
+ late final CurvedAnimation _eased;
+ late bool _expanded;
+ bool _hovered = false;
+
+ /// The disclosure's slot in the route's PageStorage: the chain of
+ /// PageStorageKeys from this card up to the bucket — the framework's
+ /// own identity for the spot — under a private type, so it can never
+ /// collide with what the blocks' scrollables keep at the same chain (a
+ /// scroll offset, read back as a double that a bool would break). Null
+ /// with no key in the chain: standalone use keeps the flag local.
+ _DisclosureId? _storageId;
+
+ /// The blocks' synthesized parts, kept while their text holds so the
+ /// host's `identical` copied check finds them — the markdown fence's
+ /// idiom. A grown streaming input yields a new instance, clearing a
+ /// stale check.
+ FlowCodePart? _inputPart;
+ FlowCodePart? _outputPart;
+
+ @override
+ void initState() {
+ super.initState();
+ // The thread remounts its list when a conversation crosses the
+ // viewport — which opening this card can cause — so the flag lives in
+ // the route's PageStorage under the part's key, where a remount finds
+ // it. Without a key up the tree (standalone use) the bucket reads
+ // null and writes nothing, and the flag is simply local.
+ final storageId = _storageId = _computeStorageId();
+ _expanded =
+ (storageId == null
+ ? null
+ : PageStorage.maybeOf(
+ context,
+ )?.readState(context, identifier: storageId)
+ as bool?) ??
+ widget.initiallyExpanded;
+ _controller = AnimationController(
+ vsync: this,
+ duration: _bodyDuration,
+ value: _expanded ? 1 : 0,
+ );
+ _eased = CurvedAnimation(parent: _controller, curve: Curves.easeOut);
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _controller.duration = MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : _bodyDuration;
+ }
+
+ @override
+ void dispose() {
+ _eased.dispose();
+ _controller.dispose();
+ super.dispose();
+ }
+
+ void _toggle() {
+ setState(() => _expanded = !_expanded);
+ if (_expanded) {
+ _controller.forward();
+ } else {
+ // Rebuild once dismissed so the body unmounts — its blocks stop
+ // costing layout, and nothing hidden stays focusable.
+ _controller.reverse().then((_) {
+ if (mounted) setState(() {});
+ });
+ }
+ final storageId = _storageId;
+ if (storageId != null) {
+ PageStorage.maybeOf(
+ context,
+ )?.writeState(context, _expanded, identifier: storageId);
+ }
+ widget.onExpandedChanged?.call(_expanded);
+ }
+
+ /// The PageStorageKeys from this widget up to the nearest PageStorage,
+ /// the walk the bucket itself makes to name a context's entry.
+ _DisclosureId? _computeStorageId() {
+ final keys = >[];
+ void collect(Widget widget) {
+ final key = widget.key;
+ if (key is PageStorageKey) keys.add(key);
+ }
+
+ collect(widget);
+ context.visitAncestorElements((element) {
+ collect(element.widget);
+ return element.widget is! PageStorage;
+ });
+ return keys.isEmpty ? null : _DisclosureId(keys);
+ }
+
+ FlowCodePart get _inputCodePart {
+ final input = widget.input!;
+ final cached = _inputPart;
+ if (cached != null &&
+ cached.code == input &&
+ cached.language == widget.inputLanguage) {
+ return cached;
+ }
+ return _inputPart = FlowCodePart(input, language: widget.inputLanguage);
+ }
+
+ FlowCodePart get _outputCodePart {
+ final output = widget.output!;
+ final cached = _outputPart;
+ if (cached != null &&
+ cached.code == output &&
+ cached.language == widget.outputLanguage) {
+ return cached;
+ }
+ return _outputPart = FlowCodePart(output, language: widget.outputLanguage);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.flowColors;
+ final typography = context.flowTypography;
+ final style =
+ context.flowTheme.toolStyle?.merge(widget.style) ?? widget.style;
+
+ final status = widget.status;
+ final title = widget.title;
+ final detail = widget.detail;
+ final errorMessage = widget.errorMessage;
+ final hasBody = widget.input != null || widget.output != null;
+ final running = status == FlowToolStatus.running;
+ final settled =
+ status == FlowToolStatus.complete || status == FlowToolStatus.error;
+ final reduceMotion = MediaQuery.disableAnimationsOf(context);
+
+ // The mark: one 16px slot the status morphs in. Pending and running
+ // share the thinking line's asterisk under one key — still, then
+ // turning — so the flip starts the spin from upright without a
+ // remount; the settled glyphs crossfade in at the same size.
+ final Widget mark = switch (status) {
+ FlowToolStatus.pending || FlowToolStatus.running => FlowThinkingIndicator(
+ key: const ValueKey('asterisk'),
+ active: running,
+ size: _markSize,
+ color: running
+ ? style?.runningColor ?? colors.onSurfaceMuted
+ : style?.pendingColor ?? colors.onSurfaceMuted,
+ ),
+ FlowToolStatus.complete => Icon(
+ Icons.check,
+ key: const ValueKey('complete'),
+ size: _markSize,
+ color: style?.completeColor ?? colors.success,
+ ),
+ FlowToolStatus.error => Icon(
+ Icons.error_outline,
+ key: const ValueKey('error'),
+ size: _markSize,
+ color: style?.errorColor ?? colors.error,
+ ),
+ };
+
+ // The title: muted while the runtime holds the call, sweeping muted
+ // to full while it runs, the ramp's variant once settled — the state
+ // ink rides the shimmer's base, since the sweep draws with its own
+ // inks and a style colour only shows while static.
+ final titleStyle = title != null
+ ? typography.labelMedium.merge(style?.titleStyle)
+ : typography.codeInline.merge(style?.nameStyle);
+ final Widget titleText = DefaultTextStyle.merge(
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ child: FlowShimmerText(
+ text: title ?? widget.name,
+ enabled: running,
+ style: titleStyle,
+ baseColor: settled ? colors.onSurfaceVariant : colors.onSurfaceMuted,
+ ),
+ );
+
+ // The header is chrome, like the code block's: the title already
+ // stays out of a thread's selection (the shimmer text always does),
+ // and the chip follows it.
+ Widget? chip;
+ if (detail != null) {
+ chip = Container(
+ padding: _chipPadding,
+ decoration: BoxDecoration(
+ color: style?.detailChipColor ?? colors.surfaceContainer,
+ borderRadius: _chipRadius,
+ ),
+ child: SelectionContainer.disabled(
+ child: Text(
+ detail,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: typography.codeInline
+ .copyWith(color: colors.onSurfaceVariant, height: 1.3)
+ .merge(style?.detailStyle),
+ ),
+ ),
+ );
+ }
+
+ // The chevron rides the body's clock, so one reduced-motion gate
+ // covers both.
+ Widget? chevron;
+ if (hasBody) {
+ chevron = RotationTransition(
+ turns: _eased.drive(Tween(begin: 0, end: _chevronTurns)),
+ child: Icon(
+ Icons.expand_more,
+ size: _chevronSize,
+ color: colors.onSurfaceMuted,
+ ),
+ );
+ }
+
+ Widget header = Container(
+ constraints: const BoxConstraints(minHeight: _headerMinHeight),
+ padding: _headerPadding,
+ child: Row(
+ children: [
+ SizedBox.square(
+ dimension: _markSize,
+ child: AnimatedSwitcher(
+ duration: reduceMotion ? Duration.zero : _markDuration,
+ child: mark,
+ ),
+ ),
+ const SizedBox(width: _gap),
+ // The title and chip share the row's middle, each giving way
+ // at half of it under pressure, and the chevron holds the
+ // trailing edge.
+ Expanded(
+ child: Row(
+ children: [
+ Flexible(child: titleText),
+ if (chip != null) ...[
+ const SizedBox(width: _gap),
+ Flexible(child: chip),
+ ],
+ ],
+ ),
+ ),
+ if (chevron != null) ...[const SizedBox(width: _gap), chevron],
+ ],
+ ),
+ );
+ // A transparent Material inside the decorated card, so the row's ink
+ // paints over the fill rather than under it — the copy button's
+ // idiom; the card's clip rounds the wash's corners.
+ header = Material(
+ type: MaterialType.transparency,
+ child: InkWell(
+ onTap: hasBody ? _toggle : null,
+ hoverColor: style?.hoverColor ?? colors.surfaceContainerLow,
+ child: header,
+ ),
+ );
+ // Excluding the subtree keeps the title from reading twice, but it
+ // drops the InkWell's tap action with it — the node re-owns
+ // activation, and says whether the body is open.
+ header = Semantics(
+ button: hasBody,
+ expanded: hasBody ? _expanded : null,
+ label: widget.semanticLabel ?? [title ?? widget.name, ?detail].join(', '),
+ excludeSemantics: true,
+ onTap: hasBody ? _toggle : null,
+ child: header,
+ );
+
+ Widget? errorLine;
+ if (errorMessage != null) {
+ errorLine = Padding(
+ padding: _errorPadding,
+ // A failure lands unprompted — announce it.
+ child: Semantics(
+ liveRegion: true,
+ child: Text(
+ errorMessage,
+ style: typography.bodyMedium
+ .copyWith(color: colors.onSurfaceVariant)
+ .merge(style?.errorMessageStyle),
+ ),
+ ),
+ );
+ }
+
+ Widget? body;
+ if (hasBody) {
+ // The disclosure: a clipped box whose height factor eases between
+ // shut and open, the framework's own structure — AnimatedSize would
+ // drop the content on the first frame of a collapse. The body is
+ // built only while open or closing, so nothing hidden costs layout
+ // or takes focus.
+ final closed = !_expanded && _controller.isDismissed;
+ body = AnimatedBuilder(
+ animation: _eased,
+ builder: (context, child) => ClipRect(
+ child: Align(
+ alignment: AlignmentDirectional.topStart,
+ heightFactor: _eased.value,
+ child: child,
+ ),
+ ),
+ child: closed ? null : _buildBody(colors, style),
+ );
+ }
+
+ return MouseRegion(
+ onEnter: (_) => setState(() => _hovered = true),
+ onExit: (_) => setState(() => _hovered = false),
+ child: Container(
+ width: double.infinity,
+ clipBehavior: Clip.antiAlias,
+ decoration: BoxDecoration(
+ color: style?.backgroundColor ?? colors.surfaceContainerLowest,
+ borderRadius: widget.borderRadius ?? _radius,
+ // Hover lives on the edge, the code block's idiom: the hairline
+ // firms from `outline` to `outlineVariant`, and a style's
+ // borderColor holds through hover unless the style names its
+ // own hover edge.
+ border: Border.all(
+ color: _hovered
+ ? (style?.hoverBorderColor ??
+ style?.borderColor ??
+ colors.outlineVariant)
+ : (style?.borderColor ?? colors.outline),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ mainAxisSize: MainAxisSize.min,
+ children: [header, ?errorLine, ?body],
+ ),
+ ),
+ );
+ }
+
+ /// The open body: the rule, then the blocks — input streaming while
+ /// the call is still being assembled, output while it runs.
+ Widget _buildBody(FlowColors colors, FlowToolStyle? style) {
+ final input = widget.input;
+ final output = widget.output;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ height: _dividerThickness,
+ color: style?.dividerColor ?? colors.outline,
+ ),
+ Padding(
+ padding: widget.padding ?? _bodyPadding,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (input != null)
+ _buildBlock(
+ part: _inputCodePart,
+ label: widget.inputLabel,
+ streaming: widget.status == FlowToolStatus.pending,
+ ),
+ if (input != null && output != null)
+ const SizedBox(height: _blockGap),
+ if (output != null)
+ _buildBlock(
+ part: _outputCodePart,
+ label: widget.outputLabel,
+ streaming: widget.status == FlowToolStatus.running,
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+
+ /// One block, wrapping rather than scrolling: a horizontal scroll
+ /// inside a disclosure inside a thread is a scroll too many, and a
+ /// result already delivered must not hide past an edge.
+ Widget _buildBlock({
+ required FlowCodePart part,
+ required String? label,
+ required bool streaming,
+ }) {
+ final onCodeCopy = widget.onCodeCopy;
+ return FlowCodeBlock(
+ code: part.code,
+ language: part.language,
+ filename: label,
+ wrap: true,
+ borderRadius: _blockRadius,
+ isStreaming: streaming,
+ onCopy: onCodeCopy == null ? null : () => onCodeCopy(part),
+ copied: identical(part, widget.copiedCodePart),
+ copyTooltip: widget.codeCopyTooltip,
+ );
+ }
+}
+
+/// A tool card's PageStorage identifier: the key chain the bucket would
+/// compute for the card's context, in a type of its own.
+@immutable
+class _DisclosureId {
+ const _DisclosureId(this.keys);
+
+ final List> keys;
+
+ @override
+ bool operator ==(Object other) =>
+ other is _DisclosureId && listEquals(other.keys, keys);
+
+ @override
+ int get hashCode => Object.hashAll(keys);
+}
diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart
index 685e1ab..80db319 100644
--- a/playground/lib/src/demo_registry.dart
+++ b/playground/lib/src/demo_registry.dart
@@ -21,6 +21,7 @@ import 'demos/thinking_indicator_demo.dart';
import 'demos/thread_demo.dart';
import 'demos/thread_list_demo.dart';
import 'demos/toast_demo.dart';
+import 'demos/tool_demo.dart';
import 'playground_item.dart';
/// The stage's demo for [item]. Keyed on the variant so switching pills
@@ -42,6 +43,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) {
PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant),
PlaygroundItem.confirmation => ConfirmationDemo(key: key, variant: variant),
PlaygroundItem.toast => ToastDemo(key: key, variant: variant),
+ PlaygroundItem.tool => ToolDemo(key: key, variant: variant),
PlaygroundItem.addToChat => AddToChatDemo(key: key),
PlaygroundItem.pill => PillDemo(key: key, variant: variant),
PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant),
@@ -115,6 +117,13 @@ List<(String, String)> variantsFor(PlaygroundItem item) {
('stacked', 'Stacked'),
('card', 'Card'),
],
+ PlaygroundItem.tool => const [
+ ('live', 'Live run'),
+ ('complete', 'Complete'),
+ ('running', 'Running'),
+ ('error', 'Error'),
+ ('thread', 'In a thread'),
+ ],
PlaygroundItem.pill => const [
('default', 'Default'),
('icon', 'Icon only'),
@@ -186,6 +195,7 @@ String snippetFor(PlaygroundItem item, {String? variant}) {
PlaygroundItem.errorState => errorStateSnippet(variant),
PlaygroundItem.confirmation => confirmationSnippet(variant),
PlaygroundItem.toast => toastSnippet(variant),
+ PlaygroundItem.tool => toolSnippet(variant),
PlaygroundItem.addToChat => addToChatSnippet,
PlaygroundItem.pill => pillSnippet(variant),
PlaygroundItem.attachments => attachmentsSnippet(variant),
diff --git a/playground/lib/src/demos/tool_demo.dart b/playground/lib/src/demos/tool_demo.dart
new file mode 100644
index 0000000..5e228a7
--- /dev/null
+++ b/playground/lib/src/demos/tool_demo.dart
@@ -0,0 +1,355 @@
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'package:flow_ui/flow_ui.dart';
+import 'package:flutter/services.dart';
+import 'package:material_ui/material_ui.dart';
+
+String toolSnippet([String? variant]) => switch (variant) {
+ 'complete' => _completeSnip,
+ 'running' => _runningSnip,
+ 'error' => _errorSnip,
+ 'thread' => _threadSnip,
+ _ => _liveSnip,
+};
+
+const String _liveSnip = '''
+// The card renders state and never advances on its own: the host
+// re-renders the part as its provider reports each step.
+
+// 1. Staged: the arguments still arriving, the input block streaming.
+FlowTool(
+ name: 'search_docs',
+ title: 'Searching the docs',
+ status: FlowToolStatus.pending,
+ input: '{"query": "draft pers',
+)
+
+// 2. Executing: the asterisk turns and the title shimmers.
+FlowTool(
+ name: 'search_docs',
+ title: 'Searching the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.running,
+ input: '{"query": "draft persistence"}',
+)
+
+// 3. Settled: the check lands and the output joins the disclosure.
+FlowTool(
+ name: 'search_docs',
+ title: 'Searched the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.complete,
+ input: '{"query": "draft persistence"}',
+ output: '{"count": 2, "best": "/components/composer#drafts"}',
+ inputLabel: 'Input',
+ outputLabel: 'Output',
+)''';
+
+const String _completeSnip = '''
+// The full anatomy, landed open: the settled check, the host's title
+// and the call's primary argument on the row, then the input and
+// output as code blocks behind the disclosure. Copy hands back a
+// FlowCodePart, the same contract as any code block.
+FlowTool(
+ name: 'search_docs',
+ title: 'Searched the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.complete,
+ input: '{"query": "draft persistence"}',
+ output: '{"count": 2, "best": "/components/composer#drafts"}',
+ inputLabel: 'Input',
+ outputLabel: 'Output',
+ initiallyExpanded: true,
+ codeCopyTooltip: 'Copy',
+ copiedCodePart: copied,
+ onCodeCopy: (part) => copy(part),
+)''';
+
+const String _runningSnip = '''
+// Executing: the thinking line's asterisk turns and the title shimmers.
+// Status is the mark, never words, so there is nothing to localize.
+FlowTool(
+ name: 'search_docs',
+ title: 'Searching the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.running,
+ input: '{"query": "draft persistence"}',
+ inputLabel: 'Input',
+)''';
+
+const String _errorSnip = '''
+// A failed call: the error glyph, and the host's message under the
+// header where it reads without a tap. The input stays behind the
+// disclosure for whoever wants to see what was asked.
+FlowTool(
+ name: 'fetch_weather',
+ title: 'Fetching the forecast',
+ detail: 'Berlin',
+ status: FlowToolStatus.error,
+ input: '{"city": "Berlin", "units": "metric"}',
+ errorMessage: 'The weather service timed out after 10 seconds.',
+ inputLabel: 'Input',
+)''';
+
+const String _threadSnip = '''
+// In a thread the card renders on its own: a FlowToolPart in any turn
+// becomes it. The section labels are thread-level, copy goes through
+// the same handler as every code block, and the part's id keys the
+// card so its disclosure survives the turn growing.
+FlowThread(
+ messages: messages,
+ toolInputLabel: 'Input',
+ toolOutputLabel: 'Output',
+ codeCopyTooltip: 'Copy',
+ copiedCodePart: copied,
+ onCodeCopy: (part) => copy(part),
+)
+
+FlowMessageData(
+ id: 'a1',
+ role: FlowMessageRole.assistant,
+ parts: [
+ FlowTextPart('Let me check the docs and the forecast.'),
+ FlowToolPart(
+ id: 'call_1',
+ name: 'search_docs',
+ title: 'Searched the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.complete,
+ input: '{"query": "draft persistence"}',
+ output: '{"count": 2, "best": "/components/composer#drafts"}',
+ ),
+ FlowToolPart(
+ id: 'call_2',
+ name: 'fetch_weather',
+ title: 'Fetching the forecast',
+ detail: 'Berlin',
+ status: FlowToolStatus.error,
+ input: '{"city": "Berlin", "units": "metric"}',
+ errorMessage: 'The weather service timed out after 10 seconds.',
+ ),
+ FlowTextPart('Drafts persist per thread. I could not reach the '
+ 'weather service.'),
+ ],
+)''';
+
+const String _searchInput = '''
+{
+ "query": "draft persistence",
+ "limit": 5
+}''';
+
+const String _searchOutput = '''
+{
+ "count": 2,
+ "results": [
+ {"title": "Composer drafts", "path": "/components/composer#drafts"},
+ {"title": "Restoring a thread", "path": "/components/message-thread#restore"}
+ ]
+}''';
+
+const String _weatherInput = '''
+{
+ "city": "Berlin",
+ "units": "metric"
+}''';
+
+const String _weatherError = 'The weather service timed out after 10 seconds.';
+
+/// Stage demo for `FlowTool` — a live run replayed on a loop, the three
+/// settled forms, and a turn in a thread carrying two calls, one of which
+/// failed.
+class ToolDemo extends StatefulWidget {
+ const ToolDemo({super.key, this.variant});
+
+ final String? variant;
+
+ @override
+ State createState() => _ToolDemoState();
+}
+
+class _ToolDemoState extends State {
+ /// The live run's clock: the input fed three characters a beat while
+ /// staged, then the two settles, a pause, and around again.
+ static const Duration _feedTick = Duration(milliseconds: 45);
+ static const int _feedStep = 3;
+ static const Duration _runDelay = Duration(milliseconds: 600);
+ static const Duration _settleDelay = Duration(milliseconds: 1400);
+ static const Duration _restartDelay = Duration(milliseconds: 2400);
+ static const Duration _copiedHold = Duration(milliseconds: 1500);
+
+ FlowToolStatus _status = FlowToolStatus.pending;
+ int _fed = 0;
+ Timer? _feed;
+ Timer? _step;
+ Timer? _copiedReset;
+ FlowCodePart? _copied;
+
+ bool get _live => widget.variant == null || widget.variant == 'live';
+
+ @override
+ void initState() {
+ super.initState();
+ if (_live) _startRun();
+ }
+
+ @override
+ void dispose() {
+ _feed?.cancel();
+ _step?.cancel();
+ _copiedReset?.cancel();
+ super.dispose();
+ }
+
+ void _startRun() {
+ _feed?.cancel();
+ _step?.cancel();
+ _status = FlowToolStatus.pending;
+ _fed = 0;
+ _feed = Timer.periodic(_feedTick, (timer) {
+ if (!mounted) return;
+ setState(() => _fed = math.min(_fed + _feedStep, _searchInput.length));
+ if (_fed < _searchInput.length) return;
+ timer.cancel();
+ _step = Timer(_runDelay, () {
+ if (!mounted) return;
+ setState(() => _status = FlowToolStatus.running);
+ _step = Timer(_settleDelay, () {
+ if (!mounted) return;
+ setState(() => _status = FlowToolStatus.complete);
+ _step = Timer(_restartDelay, () {
+ if (mounted) setState(_startRun);
+ });
+ });
+ });
+ });
+ }
+
+ void _copy(FlowCodePart part) {
+ Clipboard.setData(ClipboardData(text: part.code));
+ _copiedReset?.cancel();
+ setState(() => _copied = part);
+ _copiedReset = Timer(_copiedHold, () {
+ if (mounted) setState(() => _copied = null);
+ });
+ }
+
+ /// Thread variant: a turn that made two calls, one of which failed,
+ /// and the prose that follows them — the shape of an agent's turn.
+ List get _messages => [
+ FlowMessageData.text(
+ id: 'u1',
+ role: FlowMessageRole.user,
+ text:
+ 'Do drafts persist between sessions? And is it raining in '
+ 'Berlin?',
+ ),
+ const FlowMessageData(
+ id: 'a1',
+ role: FlowMessageRole.assistant,
+ parts: [
+ FlowTextPart('Let me check the docs and the forecast.'),
+ FlowToolPart(
+ id: 'call_1',
+ name: 'search_docs',
+ title: 'Searched the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.complete,
+ input: _searchInput,
+ output: _searchOutput,
+ ),
+ FlowToolPart(
+ id: 'call_2',
+ name: 'fetch_weather',
+ title: 'Fetching the forecast',
+ detail: 'Berlin',
+ status: FlowToolStatus.error,
+ input: _weatherInput,
+ errorMessage: _weatherError,
+ ),
+ FlowTextPart(
+ 'Drafts persist per thread: the composer keeps what you typed '
+ 'and restores it when you come back. I could not reach the '
+ 'weather service, so I cannot say whether it is raining.',
+ ),
+ ],
+ ),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ final complete = _status == FlowToolStatus.complete;
+ final pending = _status == FlowToolStatus.pending;
+ final child = switch (widget.variant) {
+ 'complete' => FlowTool(
+ name: 'search_docs',
+ title: 'Searched the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.complete,
+ input: _searchInput,
+ output: _searchOutput,
+ inputLabel: 'Input',
+ outputLabel: 'Output',
+ initiallyExpanded: true,
+ codeCopyTooltip: 'Copy',
+ copiedCodePart: _copied,
+ onCodeCopy: _copy,
+ ),
+ 'running' => const FlowTool(
+ name: 'search_docs',
+ title: 'Searching the docs',
+ detail: 'draft persistence',
+ status: FlowToolStatus.running,
+ input: _searchInput,
+ inputLabel: 'Input',
+ ),
+ 'error' => const FlowTool(
+ name: 'fetch_weather',
+ title: 'Fetching the forecast',
+ detail: 'Berlin',
+ status: FlowToolStatus.error,
+ input: _weatherInput,
+ errorMessage: _weatherError,
+ inputLabel: 'Input',
+ ),
+ 'thread' => SizedBox(
+ height: 420,
+ child: FlowThread(
+ messages: _messages,
+ toolInputLabel: 'Input',
+ toolOutputLabel: 'Output',
+ codeCopyTooltip: 'Copy',
+ copiedCodePart: _copied,
+ onCodeCopy: _copy,
+ ),
+ ),
+ _ => FlowTool(
+ name: 'search_docs',
+ title: complete ? 'Searched the docs' : 'Searching the docs',
+ detail: pending ? null : 'draft persistence',
+ status: _status,
+ input: !pending
+ ? _searchInput
+ : _fed == 0
+ ? null
+ : _searchInput.substring(0, _fed),
+ output: complete ? _searchOutput : null,
+ inputLabel: 'Input',
+ outputLabel: 'Output',
+ codeCopyTooltip: 'Copy',
+ copiedCodePart: _copied,
+ onCodeCopy: _copy,
+ ),
+ };
+
+ return Center(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxWidth: widget.variant == 'thread' ? 560 : 480,
+ ),
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart
index 0981389..e461edf 100644
--- a/playground/lib/src/playground_item.dart
+++ b/playground/lib/src/playground_item.dart
@@ -41,6 +41,7 @@ enum PlaygroundItem {
'flow_confirmation.dart',
),
toast('Toast', PhosphorIconsRegular.bellSimple, 'flow_toast.dart'),
+ tool('Tool', PhosphorIconsRegular.wrench, 'flow_tool.dart'),
addToChat(
'Add to Chat',
PhosphorIconsRegular.plus,