add clide ui toast — raise a toast from the CLI (T-245)

The drive-half complement to the toast system (D-6 parity, like ui.open):
`clide ui toast "msg" [--severity success|warning|error|info] [--duration MS]`
publishes a message on the kernel MessageBus 'toast' channel, which the
ToastService consumes — so a hosted Claude session or any script can surface
"done/failed" on the user's screen. The channel literal is kept in ui_command
(not imported from the kernel) so the daemon command stays Flutter-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:30:46 +02:00
co-authored by Claude Opus 4.8
parent 1f09abcfd7
commit 62c6a60ec7
3 changed files with 105 additions and 0 deletions
+3
View File
@@ -23,6 +23,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
dismissable, with success/warning/error/info severities. Components raise
them by publishing to the kernel MessageBus — git push/pull show the first
ones. (T-50)
- `clide ui toast "message" [--severity …] [--duration MS]` raises a toast in
the live GUI from the CLI — so an agent or script can surface "done/failed"
on your screen. The drive-half complement to the toast system. (T-245)
- Claude pane folds runs of tool calls/results into a collapsible "activity
card" so prose isn't buried: collapsed by default with a live one-line ticker
+ step count, click/Enter to expand. Claude prose, user messages, and failed
+49
View File
@@ -33,8 +33,13 @@ const Map<String, ({String publisher, String dataKey})> _readers = {
'markdown': (publisher: 'builtin.markdown', dataKey: 'path'),
};
/// Severities the toast verb accepts — mirrors `ToastSeverity` (kept as a
/// literal so this file stays Flutter-free for `dart test`).
const Set<String> _toastSeverities = {'success', 'warning', 'error', 'info'};
void registerUiCommands(DaemonDispatcher d, MessagePublisher? Function() publisher) {
d.register('ui.open', (req) async => _open(req, publisher));
d.register('ui.toast', (req) async => _toast(req, publisher));
}
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
@@ -70,3 +75,47 @@ Future<IpcResponse> _open(IpcRequest req, MessagePublisher? Function() publisher
publish(target.publisher, 'selection', {target.dataKey: ref});
return IpcResponse.ok(id: req.id, data: {'reader': reader, 'ref': ref, 'opened': true});
}
/// `clide ui toast "message" [--severity success|warning|error|info]
/// [--duration MS]` — raise a toast in the live GUI from the CLI. The
/// drive-half complement for operation feedback (T-50): an agent (or script)
/// can surface "done / failed" to the user's screen. Publishes a message on
/// the `toast` channel that the kernel ToastService consumes — the same path
/// a UI emitter uses, so no GUI coupling here.
Future<IpcResponse> _toast(IpcRequest req, MessagePublisher? Function() publisherSource) async {
final positional = (req.args['positional'] as List?)?.whereType<String>().toList() ?? const <String>[];
final flags = req.args['flags'] as Map?;
// Message: a named arg, else all positionals joined (so an unquoted
// multi-word message still works).
final message = (req.args['message'] as String?) ?? (positional.isNotEmpty ? positional.join(' ') : null);
if (message == null || message.trim().isEmpty) {
return _userErr(req.id, 'a message is required (e.g. `ui toast "Build finished"`)');
}
final severity = (flags?['severity'] as String?) ?? 'info';
if (!_toastSeverities.contains(severity)) {
return _userErr(req.id, 'unknown severity: $severity', hint: 'one of: ${_toastSeverities.join(', ')}');
}
int? durationMs;
final durRaw = flags?['duration'];
if (durRaw != null) {
durationMs = int.tryParse('$durRaw');
if (durationMs == null) {
return _userErr(req.id, 'duration must be an integer number of milliseconds');
}
}
final publish = publisherSource();
if (publish == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
);
}
// Channel literal must match ToastService's `toastChannel`.
publish('cli', 'toast', {
'message': message,
'severity': severity,
if (durationMs != null) 'durationMs': durationMs,
});
return IpcResponse.ok(id: req.id, data: {'message': message, 'severity': severity, 'shown': true});
}
+53
View File
@@ -75,4 +75,57 @@ void main() {
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
// -- ui.toast (T-50 drive-half) -------------------------------------------
Future<IpcResponse> toast(List<String> positional, {Map<String, Object?>? flags}) => d.dispatch(
IpcRequest(id: '1', cmd: 'ui.toast', args: {'positional': positional, if (flags != null) 'flags': flags}),
);
test('ui toast publishes a toast message (default info severity)', () async {
wire();
final r = await toast(['Build', 'finished']); // unquoted multi-word joins
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['shown'], isTrue);
expect(published.single.publisher, 'cli');
expect(published.single.channel, 'toast');
expect(published.single.data, {'message': 'Build finished', 'severity': 'info'});
});
test('ui toast honours --severity and --duration', () async {
wire();
final r = await toast(['Pushed'], flags: {'severity': 'success', 'duration': '2000'});
expect(r.ok, isTrue);
expect(published.single.data, {'message': 'Pushed', 'severity': 'success', 'durationMs': 2000});
});
test('ui toast rejects an unknown severity', () async {
wire();
final r = await toast(['x'], flags: {'severity': 'bogus'});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.userError);
expect(published, isEmpty);
});
test('ui toast rejects a non-integer duration', () async {
wire();
final r = await toast(['x'], flags: {'duration': 'soon'});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.userError);
});
test('ui toast with no message → userError', () async {
wire();
final r = await toast([]);
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.userError);
expect(published, isEmpty);
});
test('ui toast with no live UI → toolError', () async {
wire(liveUi: false);
final r = await toast(['hi']);
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
}