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
+67
View File
@@ -0,0 +1,67 @@
/// Tests for [FilterStateCache] — the observe-half backing store for
/// `clide ui filter` (T-270). It listens on the MessageBus `filter.state`
/// channel and remembers the latest value per address.
library;
import 'package:clide/kernel/src/events/filter_state.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:test/test.dart';
void main() {
late MessageBus bus;
late FilterStateCache cache;
setUp(() {
bus = MessageBus();
cache = FilterStateCache(messages: bus);
});
tearDown(() {
cache.dispose();
bus.dispose();
});
// Bus delivery is async (broadcast stream), so settle a turn after publish.
Future<void> settle() => Future<void>.delayed(Duration.zero);
test('returns null for an address that never reported', () {
expect(cache.get('decisions.panel'), isNull);
});
test('remembers the latest filter.state value per address', () async {
bus.publish('decisions.panel', 'filter.state', {'query': 'git'});
await settle();
expect(cache.get('decisions.panel'), 'git');
bus.publish('decisions.panel', 'filter.state', {'query': 'pql'});
await settle();
expect(cache.get('decisions.panel'), 'pql', reason: 'latest wins');
});
test('keeps addresses independent', () async {
bus.publish('decisions.panel', 'filter.state', {'query': 'git'});
bus.publish('files.tree', 'filter.state', {'query': 'lib'});
await settle();
expect(cache.get('decisions.panel'), 'git');
expect(cache.get('files.tree'), 'lib');
});
test('ignores other channels', () async {
bus.publish('decisions.panel', 'filter.set', {'query': 'git'});
await settle();
expect(cache.get('decisions.panel'), isNull, reason: 'only filter.state feeds the cache');
});
test('a missing query is treated as empty', () async {
bus.publish('decisions.panel', 'filter.state', {});
await settle();
expect(cache.get('decisions.panel'), '');
});
test('stops updating after dispose', () async {
cache.dispose();
bus.publish('decisions.panel', 'filter.state', {'query': 'git'});
await settle();
expect(cache.get('decisions.panel'), isNull);
});
}