diff --git a/CHANGELOG.md b/CHANGELOG.md index 7605e14b..9a08ce95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/src/daemon/ui_command.dart b/lib/src/daemon/ui_command.dart index 9ed55c1d..e4f276aa 100644 --- a/lib/src/daemon/ui_command.dart +++ b/lib/src/daemon/ui_command.dart @@ -33,8 +33,13 @@ const Map _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 _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 _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 _toast(IpcRequest req, MessagePublisher? Function() publisherSource) async { + final positional = (req.args['positional'] as List?)?.whereType().toList() ?? const []; + 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}); +} diff --git a/test/daemon/ui_command_test.dart b/test/daemon/ui_command_test.dart index 151f9c4a..b11e7e94 100644 --- a/test/daemon/ui_command_test.dart +++ b/test/daemon/ui_command_test.dart @@ -75,4 +75,57 @@ void main() { expect(r.ok, isFalse); expect(r.error?.kind, IpcErrorKind.toolError); }); + + // -- ui.toast (T-50 drive-half) ------------------------------------------- + + Future toast(List positional, {Map? 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); + }); }