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
+74 -4
View File
@@ -12,13 +12,22 @@ void main() {
late List<({String publisher, String channel, Map<String, Object?> data})> published;
late DaemonDispatcher d;
// Backing store for the ui.filter observe-half (the FilterStateCache in
// production). Null getter ⇒ no live UI to observe.
late Map<String, String> filterValues;
void wire({bool liveUi = true}) {
published = [];
filterValues = {};
d = DaemonDispatcher();
registerUiCommands(d, () {
if (!liveUi) return null;
return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data));
});
registerUiCommands(
d,
() {
if (!liveUi) return null;
return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data));
},
filterValue: liveUi ? (address) => filterValues[address] : null,
);
}
Future<IpcResponse> open(List<String> positional) => d.dispatch(
@@ -138,4 +147,65 @@ void main() {
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
// -- ui.filter (T-270 drive+observe half) ---------------------------------
Future<IpcResponse> filter(List<String> positional) => d.dispatch(
IpcRequest(id: '1', cmd: 'ui.filter', args: {'positional': positional}),
);
test('ui filter <address> <text> publishes a filter.set', () async {
wire();
final r = await filter(['decisions.panel', 'git']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['set'], isTrue);
expect(published.single.publisher, 'decisions.panel');
expect(published.single.channel, 'filter.set');
expect(published.single.data, {'query': 'git'});
});
test('ui filter <address> "" clears (drives an empty query)', () async {
wire();
final r = await filter(['decisions.panel', '']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(published.single.data, {'query': ''});
});
test('ui filter <address> with no text observes the cached value', () async {
wire();
filterValues['decisions.panel'] = 'git';
final r = await filter(['decisions.panel']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data, {'address': 'decisions.panel', 'query': 'git'});
expect(published, isEmpty, reason: 'observe must not publish');
});
test('ui filter observe of an unknown address returns a null query', () async {
wire();
final r = await filter(['never.touched']);
expect(r.ok, isTrue);
expect(r.data, {'address': 'never.touched', 'query': null});
});
test('ui filter with no address → userError', () async {
wire();
final r = await filter([]);
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.userError);
expect(published, isEmpty);
});
test('ui filter drive with no live UI → toolError', () async {
wire(liveUi: false);
final r = await filter(['decisions.panel', 'git']);
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('ui filter observe with no live UI → toolError', () async {
wire(liveUi: false);
final r = await filter(['decisions.panel']);
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
}
+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);
});
}
+124
View File
@@ -0,0 +1,124 @@
/// Tests for [ClideFilterBox], focused on the CLI-addressable behaviour
/// added in T-270: an addressed box reacts to `filter.set` messages and
/// republishes its value on `filter.state`, while a plain (unaddressed)
/// box stays a kernel-free UI widget.
///
/// The box has an internal `Expanded`, so it needs a bounded-width
/// ancestor — we build a tight tree rather than the shared `harness()`
/// (whose canSizeOverlay hands unbounded width).
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart' show pumpAsync;
void main() {
late KernelFixture fixture;
setUp(() async => fixture = await KernelFixture.create());
tearDown(() async => fixture.dispose());
// Tight, bounded tree with a live ClideKernel so an addressed box can
// resolve the MessageBus.
Future<void> mountAddressed(WidgetTester tester, Widget child) {
return tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: ClideKernel(
services: fixture.services,
child: ClideTheme(
controller: fixture.services.theme,
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 300, height: 60, child: child),
),
),
),
),
),
);
}
String editableText(WidgetTester tester) => tester.widget<EditableText>(find.byType(EditableText)).controller.text;
testWidgets('filter.set drives the box: updates field, fires onChanged, reports state', (tester) async {
String? captured;
await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v));
await pumpAsync(tester);
fixture.services.messages.publish('test.box', 'filter.set', {'query': 'git'});
await pumpAsync(tester);
expect(captured, 'git', reason: 'onChanged fires for a programmatic set');
expect(editableText(tester), 'git', reason: 'the field shows the pushed value');
expect(fixture.services.filterStates.get('test.box'), 'git', reason: 'state is reported back for observe');
});
testWidgets('typing reports the value on filter.state (for observe)', (tester) async {
await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (_) {}));
await pumpAsync(tester);
// Initial mount reports the empty value.
expect(fixture.services.filterStates.get('test.box'), '');
await tester.enterText(find.byType(EditableText), 'lib');
await tester.pump(const Duration(milliseconds: 250)); // past the 200ms debounce
expect(fixture.services.filterStates.get('test.box'), 'lib');
});
testWidgets('only the addressed box reacts (addresses are isolated)', (tester) async {
String? captured;
await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v));
await pumpAsync(tester);
fixture.services.messages.publish('other.box', 'filter.set', {'query': 'nope'});
await pumpAsync(tester);
expect(captured, isNull);
expect(editableText(tester), isEmpty);
});
testWidgets('the clear affordance empties the field and reports an empty value', (tester) async {
String? captured;
await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v));
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText), 'git');
await tester.pump(const Duration(milliseconds: 250));
expect(captured, 'git');
await tester.tap(find.byType(GestureDetector));
await tester.pump();
expect(captured, '');
expect(editableText(tester), isEmpty);
expect(fixture.services.filterStates.get('test.box'), '');
});
testWidgets('an unaddressed box needs no ClideKernel and still fires onChanged', (tester) async {
String? captured;
// ClideTheme is required by every box's build; ClideKernel is NOT — an
// unaddressed box must never reach for the MessageBus. No kernel here.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: ClideTheme(
controller: fixture.services.theme,
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 300, height: 60, child: ClideFilterBox(onChanged: (v) => captured = v)),
),
),
),
),
);
await tester.enterText(find.byType(EditableText), 'x');
await tester.pump(const Duration(milliseconds: 250));
expect(captured, 'x');
});
}