make sidebar filter boxes CLI-addressable via the MessageBus (T-270)

The sidebar/dock filter fields (the shared ClideFilterBox) had no CLI
peer — a one-way, UI-only affordance that broke D-6 parity. Add the
drive+observe verb `clide ui filter <address> [<text>]`, routed entirely
through the kernel MessageBus pub/sub so a box reacts to a published
message identically whether the trigger was a UI keystroke or the CLI —
keeping extensions first-class (no dispatcher→widget wiring).

- ClideFilterBox gains an `address`; when set it listens on `filter.set`
  for its address and republishes its value on `filter.state`. Null
  address keeps the box a kernel-free UI widget.
- FilterStateCache (new kernel service) caches the latest `filter.state`
  per address — the bus has no retention, so this backs the observe-half.
- ui.filter: with text → publishes `filter.set` (drive); without →
  reads the cache (observe). Honest toolError when there is no live UI.
- Address every box: decisions/tickets/files/git/output/problems panes,
  the four search boxes, and the pql search/query/markdown inputs.

Addresses are the ids from `clide pane list` (e.g. decisions.panel).
settings.json: allow the `clide` CLI + relevant skills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 12:44:40 +02:00
co-authored by Claude Opus 4.8
parent f813fd4d5a
commit 826395481c
22 changed files with 511 additions and 20 deletions
+51 -1
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/icons/phosphor.dart';
@@ -14,6 +16,7 @@ class ClideFilterBox extends StatefulWidget {
this.debounce = const Duration(milliseconds: 200),
this.onSubmitted,
this.icon = PhosphorIcons.magnifyingGlass,
this.address,
});
final ValueChanged<String> onChanged;
@@ -25,6 +28,15 @@ class ClideFilterBox extends StatefulWidget {
/// that aren't searches (e.g. a replace or glob field).
final ClideIconPainter? icon;
/// Makes this box addressable from the CLI (D-6 parity, T-270). When set,
/// the box listens on the MessageBus `filter.set` channel for its address
/// — so `clide ui filter <address> <text>` drives it exactly as a UI
/// keystroke would — and republishes its value on `filter.state` so the
/// observe-half (`clide ui filter <address>`) can read it back. The
/// address is the pane/box id surfaced by `clide pane list`. When null the
/// box is a plain UI-only widget and never touches the kernel.
final String? address;
@override
State<ClideFilterBox> createState() => _ClideFilterBoxState();
}
@@ -33,18 +45,55 @@ class _ClideFilterBoxState extends State<ClideFilterBox> {
final _controller = TextEditingController();
final _focus = FocusNode();
Timer? _debounceTimer;
StreamSubscription<Message>? _busSub;
MessageBus? _bus;
bool _wired = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final address = widget.address;
if (address == null || _wired) return;
_wired = true;
final bus = ClideKernel.of(context).messages;
_bus = bus;
_busSub = bus.subscribe(publisher: address, channel: 'filter.set').listen(_onRemoteSet);
// Report the initial value so an observe before any change still reads it.
_publishState(_controller.text);
}
@override
void dispose() {
_debounceTimer?.cancel();
_busSub?.cancel();
_controller.dispose();
_focus.dispose();
super.dispose();
}
/// Apply a value pushed in over the bus. Programmatic sets take effect
/// immediately (no debounce) and report their new state.
void _onRemoteSet(Message m) {
final value = m.data['query'] as String? ?? '';
_debounceTimer?.cancel();
_controller.text = value;
widget.onChanged(value);
_publishState(value);
setState(() {});
}
void _publishState(String value) {
final address = widget.address;
if (address == null) return;
_bus?.publish(address, 'filter.state', {'query': value});
}
void _onChanged(String value) {
_debounceTimer?.cancel();
_debounceTimer = Timer(widget.debounce, () => widget.onChanged(value));
_debounceTimer = Timer(widget.debounce, () {
widget.onChanged(value);
_publishState(value);
});
setState(() {});
}
@@ -52,6 +101,7 @@ class _ClideFilterBoxState extends State<ClideFilterBox> {
_controller.clear();
_debounceTimer?.cancel();
widget.onChanged('');
_publishState('');
_focus.requestFocus();
setState(() {});
}