Skip to content

Dialog API

This page is the curated reference for Plugin Kit Dialog. It covers two packages and the line between them is the architectural point of the dialog: declaration types live in plugin_kit (Dart-only) so non-Flutter packages can declare configurable services without taking a Flutter dependency, while the actual UI, controller, theme, and visuals live in plugin_kit_dialog (Flutter).

For an end-to-end walkthrough, see the Plugin Kit Dialog guide. This page is the API.

TypePackageWhy it lives there
UiConfigurableCapabilityplugin_kitDeclaration; const-constructable from Dart-only code.
ConfigField and subclassesplugin_kitDeclaration; no Flutter types in the public surface.
ConfigFieldHandleplugin_kitRead/write contract for renderers; Object?-based.
showPluginKitDialog, PluginKitDialog, PluginKitDialogBodyplugin_kit_dialogMaterial widgets.
PluginKitDialogControllerplugin_kit_dialogChangeNotifier-backed draft.
PluginKitDialogTheme, buildPluginKitDialog{Dark,Light}Themeplugin_kit_dialogThemeExtension and ThemeData builders.
PluginKitVisualsPlugin, PluginKitVisualplugin_kit_dialogHost-app overrides for icon, color, label across plugin, namespace, and service axes.

These four exports are everything you need to declare a configurable service. None of them imports Flutter.

class UiConfigurableCapability extends Capability {
/// Section title shown at the top of the rendered card or sub-section.
final String label;
/// Optional one-line description below the title.
final String? description;
/// Field schema rendered top-to-bottom in the card.
final List<ConfigField> fields;
/// Creates a capability instance that renders one configuration card.
const UiConfigurableCapability({
required this.label,
required this.fields,
this.description,
});
}

Attach this capability to a service registration to make it editable in the Services tab. label is the section title rendered above the fields; description is an optional one-line subtitle; fields is rendered top-to-bottom in the card.

A service may attach multiple UiConfigurableCapability instances; each becomes its own sub-section under the same service card. Capability resolution otherwise follows the rules described on the Capability reference page.

void registerWithNamespace(ScopedServiceRegistry registry) {
const agent = Namespace('agent');
registry.registerSingleton<MyService>(
agent('temperature'), // ServiceId('agent.temperature')
() => const MyService(),
capabilities: const {
UiConfigurableCapability(
label: 'Temperature',
description: 'Controls randomness in responses.',
fields: [
NumberConfigField(
key: 'temperature',
label: 'Temperature',
min: 0,
max: 2,
step: 0.1,
defaultValue: 1.0,
),
],
),
},
);
}
sealed class ConfigField {
/// Dotted-key path under `ServiceSettings.config`.
/// Examples: `"model"`, `"provider.name"`, `"limits.max_tokens"`.
final String key;
/// Human-readable label shown above the input.
final String label;
/// Optional helper text rendered below the input in muted style.
final String? helperText;
/// Default value used by the "reset" button. May be null.
final Object? defaultValue;
/// Creates a config field schema entry.
const ConfigField({
required this.key,
required this.label,
this.helperText,
this.defaultValue,
});
}

Every field carries the same four slots. key is a dotted path under ServiceSettings.config ('model', 'provider.name', 'limits.max_tokens'); the dotted form writes to nested maps when the dialog saves. defaultValue is what the per-field reset button restores to. ConfigNode reads only stored settings values and returns null for absent keys. See ConfigNode for the read-side contract.

The sealed modifier means the dialog’s renderer dispatch is exhaustive; you cannot add new field types without declaring a new renderer for them through ExtensionConfigField.

final class TextConfigField extends ConfigField {
/// Placeholder text shown when no value is set.
final String? placeholder;
/// Creates a single-line text field schema.
const TextConfigField({
required super.key,
required super.label,
super.helperText,
super.defaultValue,
this.placeholder,
});
}

Single-line text input. placeholder shows when no value is set.

final class MultilineConfigField extends ConfigField {
/// Suggested moustache tags displayed as hint chips under the editor.
final List<String> moustacheTags;
/// Minimum number of visible lines for the editor.
final int? minLines;
/// Maximum number of visible lines for the editor.
final int? maxLines;
/// Creates a multi-line text field schema.
const MultilineConfigField({
required super.key,
required super.label,
super.helperText,
super.defaultValue,
this.moustacheTags = const [],
this.minLines = 6,
this.maxLines = 14,
});
}

Multi-line editor sized between minLines and maxLines. moustacheTags declares insertable tag chips shown under the editor; tapping a chip inserts the tag at the caret. Useful for system-prompt fields that accept template variables like {{user_name}} or {{tool_list}}.

final class PasswordConfigField extends ConfigField {
/// Placeholder text shown when no value is set.
final String? placeholder;
/// Creates a password field schema with obscured input.
const PasswordConfigField({
required super.key,
required super.label,
super.helperText,
super.defaultValue,
this.placeholder,
});
}

Obscured input with a show/hide toggle. Same shape as TextConfigField; rendered with the entry hidden by default.

final class NumberConfigField extends ConfigField {
/// Minimum numeric value allowed by the field.
final double? min;
/// Maximum numeric value allowed by the field.
final double? max;
/// Step size used when adjusting numeric values. When null and [isInteger]
/// is true, defaults to 1.
final double? step;
/// Force a specific render style. Null = auto-pick from [min]/[max].
final NumberFieldStyle? style;
/// When true, values are stored as `int` and decimals are stripped.
final bool isInteger;
/// Creates a numeric field schema.
const NumberConfigField({
required super.key,
required super.label,
super.helperText,
super.defaultValue,
this.min,
this.max,
this.step,
this.style,
this.isInteger = false,
});
}

Numeric input with two render modes. When style is null (the default), the dialog auto-picks: slider when both min and max are non-null, otherwise text input. Set style explicitly to force a mode.

When isInteger is true, values are stored as int, parsing strips decimals, and the slider step defaults to 1 if step is null.

enum NumberFieldStyle {
/// Render as a slider with an inline value badge.
slider,
/// Render as a numeric text field. Bounds (when present) clamp the parsed
/// value rather than constraining the slider.
textInput,
}

Forces one render mode for NumberConfigField. With textInput, any min/max clamp the parsed value rather than constraining a slider track.

final class DropdownConfigField<T> extends ConfigField {
/// Allowed options rendered in the dropdown.
final List<DropdownOption<T>> options;
/// Creates a typed dropdown field schema.
const DropdownConfigField({
required super.key,
required super.label,
required this.options,
super.helperText,
super.defaultValue,
});
}

Typed dropdown over List<DropdownOption<T>>. The generic flows through to the saved value: a DropdownConfigField<String> stores String in the config map.

class DropdownOption<T> {
/// Runtime value assigned when this option is selected.
final T value;
/// Human-readable label shown in the menu.
final String label;
/// Creates a dropdown option.
const DropdownOption(this.value, this.label);
}

A single selectable option. Positional constructor, value first, label second.

final class BoolConfigField extends ConfigField {
/// Creates a boolean switch field schema.
const BoolConfigField({
required super.key,
required super.label,
super.helperText,
super.defaultValue,
});
}

Switch input. Carries no extra state beyond the four base slots.

final class GroupConfigField extends ConfigField {
/// Child fields rendered inside the grouped section.
final List<ConfigField> children;
/// Creates a grouped field schema.
const GroupConfigField({
required super.key,
required super.label,
required this.children,
super.helperText,
});
}

Visual sub-section. Renders label as a sub-heading and indents children beneath it. key is the group’s write target, and nested fields read and write their own dotted keys inside the map stored at that key.

GroupConfigField does not accept defaultValue (super.defaultValue is omitted): groups have no value of their own.

final class ExtensionConfigField extends ConfigField {
/// Identifier used to look up a renderer registered with the dialog runtime.
final String rendererKey;
/// Opaque, serializable arguments forwarded to the renderer.
final Map<String, Object?> args;
/// Creates an extension field schema.
const ExtensionConfigField({
required super.key,
required super.label,
required this.rendererKey,
this.args = const {},
super.helperText,
super.defaultValue,
});
}

Escape hatch for custom renderers without taking a Flutter dependency at the declaration site. The dialog looks up a Flutter-side renderer registered under rendererKey and forwards args to it. See Custom renderers below.

abstract class ConfigFieldHandle {
/// Current working value for the bound field.
Object? get value;
/// Updates the current working value for the bound field.
set value(Object? next);
/// Whether the current value differs from the field default.
bool get isOverridden;
/// Restores the field value to its declared default.
void reset();
}

The opaque value handle a renderer receives. value reads and writes the current working value; setting it dirties the draft. isOverridden is true when the working value differs from the field’s declared default. reset() restores the value to defaultValue.

Use this from custom renderers; the built-in renderers consume it internally.

Flutter entry points (in plugin_kit_dialog)

Section titled “Flutter entry points (in plugin_kit_dialog)”

Three widgets and one controller. Pick the entry point that matches how much chrome you want to bring yourself.

Future<RuntimeSettings?> showPluginKitDialog({
required BuildContext context,
required PluginRuntime runtime,
required RuntimeSettings initialSettings,
required SaveCallback onSave,
String title = 'Plugin Kit',
PluginKitDialogTheme? theme,
bool barrierDismissible = true,
}) async {

The default entry point: opens a Material Dialog containing a PluginKitDialog, awaits user interaction, and resolves with the saved RuntimeSettings or null on cancel. The function builds its own PluginKitDialogController internally; you do not need to manage one.

onSave is the host-side persistence callback. It runs before the dialog closes, so the dialog can reflect any error you choose to surface and so you can write to disk or push to runtime.updateSettings(...) while the user is still looking at the spinner.

typedef SaveCallback = FutureOr<void> Function(RuntimeSettings);

The Future return is awaited end-to-end; throwing from onSave surfaces a SnackBar inside the dialog and leaves the dialog open with the draft intact.

theme is a PluginKitDialogTheme (a ThemeExtension), not a ThemeData. The dialog merges it onto the host’s existing theme extensions inside the showDialog builder so the dialog route picks it up even though it lives under the root navigator. Pass null to inherit the host’s theme as-is.

barrierDismissible: true is the default. Barrier taps still follow this flag while a save is in flight. System-back is blocked while saving.

class PluginKitDialog extends StatelessWidget {
/// Controller backing draft edits, dirty state, and save/reset behavior.
/// The runtime being edited is read from `controller.runtime`.
final PluginKitDialogController controller;
/// Save callback invoked with the draft settings.
final SaveCallback onSave;
/// Cancel callback invoked when dialog dismissal is requested.
final VoidCallback onCancel;
/// Creates a constrained material dialog around [PluginKitDialogBody].
const PluginKitDialog({
required this.controller,
required this.onSave,
required this.onCancel,
super.key,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
const borderRadius = BorderRadius.all(Radius.circular(20));
return Dialog(
insetPadding: const EdgeInsets.all(24),
backgroundColor: colorScheme.surface,
elevation: 24,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 920,
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
child: PluginKitDialogBody(
controller: controller,
runtime: controller.runtime,
onSave: onSave,
onCancel: onCancel,
),
),
);
}
}

The Material dialog shell with rounded corners, a maxWidth of 920, and a maxHeight of 90% of the screen. Use this when you want to drive the dialog yourself: own the controller, decide when to push and pop, intercept cancel for your own confirm flow. The runtime is read off controller.runtime.

class PluginKitDialogBody extends StatefulWidget {
/// Controller backing the editable draft state and dirty tracking.
final PluginKitDialogController controller;
/// Runtime being edited by this dialog.
final PluginRuntime runtime;
/// Save callback invoked with current working settings.
final SaveCallback onSave;
/// Cancel callback invoked when the user cancels out of the dialog.
final VoidCallback onCancel;
/// Creates a dialog body bound to [controller] and [runtime].
const PluginKitDialogBody({
required this.controller,
required this.runtime,
required this.onSave,
required this.onCancel,
super.key,
});
@override
State<PluginKitDialogBody> createState() => _PluginKitDialogBodyState();
}

The header-plus-tab-content body without the surrounding Dialog chrome. Use this when you want a custom container: a side panel, a route, a dedicated page in a desktop layout. Pass the same runtime as controller.runtime; plugin metadata and no-op pruning defaults are sourced from the controller runtime.

class PluginKitDialogController extends ChangeNotifier {
PluginKitDialogController({
required this.runtime,
required RuntimeSettings initialSettings,
});
final PluginRuntime runtime;
PluginKitDialogDraft get draft;
bool get isDirty;
bool get isSaving;
set isSaving(bool value);
bool get showAllServices;
set showAllServices(bool value);
void setPluginEnabled(PluginId pluginId, bool enabled);
void setServiceField({
required Pin scopedKey,
required String fieldKey,
required Object? value,
});
void setServiceEnabled(Pin scopedKey, bool enabled);
void setServicePriority(Pin scopedKey, int? priority);
void resetField(Pin scopedKey, String fieldKey);
void resetService(Pin scopedKey);
void resetPlugin(PluginId pluginId);
void resetAll();
void replaceWorking(RuntimeSettings parsed);
void markSaved();
}

ChangeNotifier-backed mutable container for the working draft. Construct with a runtime and the current initialSettings; the controller seeds an internal draft and tracks every mutation against it.

draft.working is the current edited RuntimeSettings; draft.active is the baseline you constructed from. isDirty flips true as soon as working diverges from active and back to false on markSaved (which the dialog body invokes automatically after onSave resolves successfully).

isSaving is owned by the dialog body, which flips it around its await onSave(...) so the header can render an inline spinner and the body can dim the tab area. Treat the setter as read-only from app code; it is public only so the body can drive it.

showAllServices is a UI-only flag for the Advanced tab JSON preview to show defaults alongside overrides.

The mutators are designed to be safe to call repeatedly. Setting a field to a value that matches the defaults plus baseline collapses the override to nothing (no-op deletion), so saving from a dirty-then-reset draft produces minimal RuntimeSettings.

Visuals are a Flutter-only concern (icons and colors), so the canonical attachment path is a single locked GlobalPlugin that carries host-app overrides keyed by the three axes the dialog renders: plugins, namespace headers, and individual service cards.

class PluginKitVisualsPlugin extends GlobalPlugin {
static const Namespace pluginVisualNamespace = Namespace('plugin_visual');
static const Namespace namespaceVisualNamespace = Namespace('namespace_visual');
static const Namespace serviceVisualNamespace = Namespace('service_visual');
static const int dialogVisualsAdapterPriority = Priority.elevated; // 1000
static const id = PluginId('plugin_kit_visuals');
final Map<PluginId, PluginKitVisual> pluginVisuals;
final Map<Namespace, PluginKitVisual> namespaceVisuals;
final Map<ServiceId, PluginKitVisual> serviceVisuals;
PluginKitVisualsPlugin({
this.pluginVisuals = const {},
this.namespaceVisuals = const {},
this.serviceVisuals = const {},
});
}

Locked GlobalPlugin (not user-toggleable) that registers host-app visual overrides. Three independent maps so a Flutter host can decorate plugins owned by Dart-only packages without those packages depending on Flutter.

The plugin registers each visual at Priority.elevated (1000), above the default registry priority Priority.normal (500), so host overrides beat anything a Flutter plugin self-attaches at the default. When two host plugins decorate the same key, the standard registry priority and registration order rules apply.

Unknown keys are accepted silently. A PluginId, Namespace, or ServiceId that no current plugin registers is retained without warning, so the visual picks up automatically when the matching plugin gets enabled later. Resolution happens at the consumption site (the dialog’s chip builder, the services tab card list); the visuals plugin itself never inspects the registry on attach. If you need to detect leftover or typo’d keys, resolve at the consumption site.

class PluginKitVisual {
final String? label;
final String? description;
final Widget? icon;
final Color? color;
const PluginKitVisual({
this.label,
this.description,
this.icon,
this.color,
});
}

Same value object across all three axes. Every field is optional; the dialog falls back to derived defaults for anything you leave null (raw pluginId or namespace name or service id, theme primary color, default namespace or service icon when visuals omit one).

icon is a full Widget, wrapped by the dialog in an IconTheme keyed to the resolved accent so a plain Icon(Icons.psychology) inherits color and size automatically. The accent color follows a service-to-namespace-to-owning-plugin-to-theme cascade resolved per render.

void addVisualsPlugin(PluginRuntime runtime, List<Plugin> myPlugins) {
runtime
..addPlugins(myPlugins)
..addPlugin(
PluginKitVisualsPlugin(
pluginVisuals: {
const PluginId('main_agent'): const PluginKitVisual(
label: 'Main Agent',
description: 'The brain. Drives chat, tools, and routing.',
icon: Icon(Icons.psychology),
color: Color(0xFF7C5CFF),
),
},
namespaceVisuals: {
const Namespace('agent'): const PluginKitVisual(
label: 'Agent',
icon: Icon(Icons.smart_toy),
color: Color(0xFF7C5CFF),
),
},
serviceVisuals: {
const Namespace('agent')('temperature'): const PluginKitVisual(
label: 'Temperature',
icon: Icon(Icons.thermostat),
color: Color(0xFFFF9500),
),
},
),
);
}

The dialog reads chrome (radii, surfaces, typography) off the host’s ThemeData and pulls domain-semantic accents off a PluginKitDialogTheme extension.

class PluginKitDialogTheme extends ThemeExtension<PluginKitDialogTheme> {
final Color stableAccent;
final Color experimentalAccent;
final Color agentAccent;
final Color statActiveBackground;
final Color statStableBackground;
final Color statExperimentalBackground;
const PluginKitDialogTheme({
required this.stableAccent,
required this.experimentalAccent,
required this.agentAccent,
required this.statActiveBackground,
required this.statStableBackground,
required this.statExperimentalBackground,
});
static PluginKitDialogTheme dark();
static PluginKitDialogTheme light();
static PluginKitDialogTheme of(BuildContext context);
@override
PluginKitDialogTheme copyWith({...});
@override
PluginKitDialogTheme lerp(...);
}

A ThemeExtension with six accent slots that Material’s ColorScheme cannot supply: stable plugin tier, experimental plugin tier, agent-config tier, plus three matching stat-chip background tints. Everything else (badge surfaces, text styles, JSON-preview surfaces) is derived from Theme.of(context).

PluginKitDialogTheme.dark() and .light() are the canonical defaults. PluginKitDialogTheme.of(context) returns the registered extension if one exists, otherwise picks dark or light from Theme.of(context).brightness. copyWith and lerp are the standard ThemeExtension overrides.

/// Demonstrates showPluginKitDialog with a custom [PluginKitDialogTheme].
Future<void> openConfigDialogThemed(
BuildContext context,
PluginRuntime myRuntime,
RuntimeSettings settings,
Future<void> Function(RuntimeSettings) persist,
) async {
await showPluginKitDialog(
context: context,
runtime: myRuntime,
initialSettings: settings,
onSave: persist,
theme: PluginKitDialogTheme.dark().copyWith(
stableAccent: Colors.greenAccent,
experimentalAccent: Colors.deepOrange,
),
);
}

buildPluginKitDialogDarkTheme and buildPluginKitDialogLightTheme

Section titled “buildPluginKitDialogDarkTheme and buildPluginKitDialogLightTheme”
ThemeData buildPluginKitDialogDarkTheme();
ThemeData buildPluginKitDialogLightTheme();

Full Material 3 ThemeData builders that match the screenshots in the guide. Use one as your MaterialApp.theme if you want the dialog to look the way it does in the demo without hand-tuning your own scheme. Both builders include a PluginKitDialogTheme.dark() or .light() extension on the returned ThemeData, so the dialog accents are wired up automatically.

Custom field widgets are resolved from the dialog runtime’s internal registry, not from the host runtime you pass into showPluginKitDialog. The public dialog widgets build that internal runtime with a fixed plugin list, so renderer plugins registered only on your host runtime are not visible there. The ConfigFieldRenderer<F> interface is the contract.

abstract interface class ConfigFieldRenderer<T extends ConfigField> {
/// Builds a field input widget for [field] using [handle].
///
/// [resolveRenderer] resolves renderers for nested fields (for example group
/// field children).
Widget build(
BuildContext context,
T field,
ConfigFieldHandle handle,
FieldRenderResolver resolveRenderer,
);
}

A renderer takes a typed field, the field’s current value handle, and a resolver for nested fields (used by group rendering). Build whatever widget you want; write through handle.value = next and the dialog tracks the edit on the working draft.

Renderers register under the namespace 'config_field_renderer' keyed by a string serviceId of your choosing. The ExtensionConfigField.rendererKey you declare on the field side is the serviceId the dialog looks up.

/// A custom field renderer for color values (Flutter-side).
class ColorPickerRenderer implements ConfigFieldRenderer<ExtensionConfigField> {
/// Creates a [ColorPickerRenderer].
const ColorPickerRenderer();
@override
Widget build(
BuildContext context,
ExtensionConfigField field,
ConfigFieldHandle handle,
FieldRenderResolver resolveRenderer,
) {
final allowAlpha = field.args['allow_alpha'] as bool? ?? false;
return Slider(
value: ((handle.value as int?) ?? 0xFF000000).toDouble(),
min: 0,
max: 0xFFFFFFFF.toDouble(),
onChanged: (next) => handle.value = next.toInt(),
label: allowAlpha ? 'ARGB' : 'RGB',
);
}
}
class ColorPickerRendererPlugin extends GlobalPlugin {
@override
PluginId get pluginId => const PluginId('color_picker_renderer');
@override
void register(ScopedServiceRegistry registry) {
registry.registerFactory<ConfigFieldRenderer>(
FieldRenderersPlugin.namespace('color_picker'),
ColorPickerRenderer.new,
);
}
}

The renderer resolves at render time: registering your plugin after the dialog opens does not affect an in-flight session. If the dialog cannot resolve a rendererKey, it surfaces an inline placeholder card naming the missing key rather than throwing during paint.