diff --git a/.claude/settings.json b/.claude/settings.json
index 4d88485b..1dd8e6c1 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,6 +1,10 @@
{
"permissions": {
"allow": [
+ "Skill(pql)",
+ "Skill(clide)",
+ "Skill(whats-next)",
+ "Skill(git-commit)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git status *)",
@@ -30,6 +34,8 @@
"Bash(make *)",
"Bash(pql)",
"Bash(pql *)",
+ "Bash(clide)",
+ "Bash(clide *)",
"Bash(awk *)"
],
"deny": [
diff --git a/.pql/changelog/ticket_deps/2026-06.sql b/.pql/changelog/ticket_deps/2026-06.sql
index fcddf84e..bc126527 100644
--- a/.pql/changelog/ticket_deps/2026-06.sql
+++ b/.pql/changelog/ticket_deps/2026-06.sql
@@ -7,3 +7,5 @@ INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-220', 'T-221', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, 'f054e1277bdd77ecfdac7b78902aa543', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-224', 'T-214', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, 'fb0b5ec4b479363fd4e4a4de1d9e5d82', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-266', 'T-264', '2026-06-07 08:49:16', '2026-06-07 08:49:16', NULL, '192fb02408f15e992ad6df469cae696e', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
+INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'T-271', '2026-06-07 10:42:46', '2026-06-07 10:42:46', NULL, 'de1cc2484bc5f4bab84aef45de148eb2', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
+INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'T-272', '2026-06-07 10:42:50', '2026-06-07 10:42:50', NULL, 'f2401654c1b6a99a98b601216d9d6091', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql
index 459cfda8..a1ca038e 100644
--- a/.pql/changelog/ticket_history/2026-06.sql
+++ b/.pql/changelog/ticket_history/2026-06.sql
@@ -766,3 +766,17 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-268', 'status', 'backlog', 'done', NULL, '2026-06-07 09:36:59', '2026-06-07 09:36:59', '2026-06-07 09:36:59', NULL, 'dcce34ebf831a8836fe31bcad6bab92c', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-269', 'status', 'backlog', 'done', NULL, '2026-06-07 09:51:32', '2026-06-07 09:51:32', '2026-06-07 09:51:32', NULL, '3fec60e8de272979ad791c313eac76f8', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'status', 'backlog', 'ready', NULL, '2026-06-07 10:03:36', '2026-06-07 10:03:36', '2026-06-07 10:03:36', NULL, '643716825d71326f6066fb04cbb67077', 1) ON CONFLICT(hash) DO NOTHING;
+INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-270', 'status', 'backlog', 'done', NULL, '2026-06-07 10:43:18', '2026-06-07 10:43:18', '2026-06-07 10:43:18', NULL, '5bae170fdf2314837253e366ae06812e', 1) ON CONFLICT(hash) DO NOTHING;
+INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'description', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.
+
+## Refinement (decisions, 2026-06-07)
+
+**v1 scope:** File, View, Help/About only. Edit and Selection are deferred to follow-ups [[T-271]] (Edit) and [[T-272]] (Selection) — both blocked on this story, since they need focused-surface command routing.
+- View submenu: panel toggles, zoom, focus mode.
+- Help: About modal (version, license, links).
+
+**Command mapping (hybrid):** A hand-authored menu tree defines curated placement — ordering, grouping, separators, and which commands sit where. Any registered command not explicitly placed auto-fills from the command registry (by category) into the matching submenu / an overflow section, so newly registered commands surface without manual wiring. Each item''s title + keybinding are pulled from the registry.
+
+**Context behavior:** Items reflect the focused surface; inapplicable items render disabled (greyed), not hidden. Mainly exercised once Edit/Selection land, but View/File items honor it too where relevant.
+
+**Keyboard / a11y:** Full keyboard support — Alt+mnemonic opens a menu, arrow-key navigation within, Enter activates, Esc / click-away closes. Must meet the a11y contract (keyboard nav + semantics).', NULL, '2026-06-07 10:43:22', '2026-06-07 10:43:22', '2026-06-07 10:43:22', NULL, '24d0f8f4ae04e75128f681498c24374f', 1) ON CONFLICT(hash) DO NOTHING;
diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql
index 2d4ea7a5..afa1171b 100644
--- a/.pql/changelog/tickets/2026-06.sql
+++ b/.pql/changelog/tickets/2026-06.sql
@@ -1621,3 +1621,19 @@ Fix (T-269):
- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:51:32', NULL, '9b92ed68f5c352d5461c01d7e98c0b50', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 10:03:36', NULL, 'e09873c62cfab523b2b4048d9681c9ae', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-270', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:23:13', NULL, '0d79acd088da9d0b240ee5473c3a21c4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-271', 'task', 'T-48', 'Edit menu (hat-bar menu bar) — routed to focused surface', 'Deferred from T-48 v1. Adds the Edit top-level menu to the hat-bar application menu (T-48). Items (undo, redo, cut, copy, paste, select-all, find/replace) reference registered command ids and route to the focused surface; inapplicable items render disabled (greyed), per T-48''s show-but-disable context behavior. Depends on focused-surface command routing so the menu reflects the active editor/pane. Reuses T-48''s menu widgets, command->item mapping, and keyboard/a11y model.', 'backlog', 'medium', NULL, NULL, 'D-7', '2026-06-07 10:42:32', '2026-06-07 10:42:32', NULL, '4896030bd5e76b49f4bcb56239b2cb96', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-272', 'task', 'T-48', 'Selection menu (hat-bar menu bar) — routed to focused surface', 'Deferred from T-48 v1. Adds the Selection top-level menu to the hat-bar application menu (T-48). Selection operations reference registered command ids and route to the focused surface; inapplicable items render disabled per T-48''s show-but-disable context behavior. Depends on focused-surface command routing. Reuses T-48''s menu widgets, command->item mapping, and keyboard/a11y model.', 'backlog', 'medium', NULL, NULL, 'D-7', '2026-06-07 10:42:38', '2026-06-07 10:42:38', NULL, '9bc20e9a0cd664e6de878fec4fe199de', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-270', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'done', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:43:18', NULL, '28f5c3ff053d5a7bf209acfa34ddfd8b', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.
+
+## Refinement (decisions, 2026-06-07)
+
+**v1 scope:** File, View, Help/About only. Edit and Selection are deferred to follow-ups [[T-271]] (Edit) and [[T-272]] (Selection) — both blocked on this story, since they need focused-surface command routing.
+- View submenu: panel toggles, zoom, focus mode.
+- Help: About modal (version, license, links).
+
+**Command mapping (hybrid):** A hand-authored menu tree defines curated placement — ordering, grouping, separators, and which commands sit where. Any registered command not explicitly placed auto-fills from the command registry (by category) into the matching submenu / an overflow section, so newly registered commands surface without manual wiring. Each item''s title + keybinding are pulled from the registry.
+
+**Context behavior:** Items reflect the focused surface; inapplicable items render disabled (greyed), not hidden. Mainly exercised once Edit/Selection land, but View/File items honor it too where relevant.
+
+**Keyboard / a11y:** Full keyboard support — Alt+mnemonic opens a menu, arrow-key navigation within, Enter activates, Esc / click-away closes. Must meet the a11y contract (keyboard nav + semantics).', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 10:43:22', NULL, '56aea322a8228babee96dec5befced5a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d50fa675..868cbbc2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
+- The sidebar/dock **filter boxes are now CLI-addressable** (D-6 parity): `clide
+ ui filter
` drives a pane's filter exactly as typing in it
+ would, and `clide ui filter ` reads the current value back. Addresses
+ are the box ids from `clide pane list` (e.g. `decisions.panel`, `files.tree`,
+ `search.findInFiles`). Routed entirely through the MessageBus pub/sub — a box
+ reacts to a published `filter.set` the same whether the trigger was a click or
+ the CLI, keeping extensions first-class. (T-270)
- Click an inline image card to open it in a full-screen **lightbox** — zoom
(scroll/pinch), pan, double-click to reset, `Esc`/backdrop to dismiss — since
the cards are often too small to read. `clide image show --fullscreen`
diff --git a/lib/builtin/decisions/src/decisions_view.dart b/lib/builtin/decisions/src/decisions_view.dart
index e40e4854..ca1a0236 100644
--- a/lib/builtin/decisions/src/decisions_view.dart
+++ b/lib/builtin/decisions/src/decisions_view.dart
@@ -150,7 +150,7 @@ class _DecisionsViewState extends State {
children: [
Row(
children: [
- Expanded(child: ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
+ Expanded(child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideTappable(
diff --git a/lib/builtin/files/src/file_tree_view.dart b/lib/builtin/files/src/file_tree_view.dart
index 42a04f3a..b7ea02e4 100644
--- a/lib/builtin/files/src/file_tree_view.dart
+++ b/lib/builtin/files/src/file_tree_view.dart
@@ -65,7 +65,7 @@ class _FileTreeViewState extends State {
final rootName = root.split(Platform.pathSeparator).last;
return Column(
children: [
- ClideFilterBox(hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
+ ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
Expanded(
child: Semantics(
label: 'file tree — $rootName',
diff --git a/lib/builtin/git/src/git_panel_view.dart b/lib/builtin/git/src/git_panel_view.dart
index f8f97e88..26339e14 100644
--- a/lib/builtin/git/src/git_panel_view.dart
+++ b/lib/builtin/git/src/git_panel_view.dart
@@ -75,7 +75,7 @@ class _GitPanelViewState extends State {
explicitChildNodes: true,
child: Column(
children: [
- ClideFilterBox(hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
+ ClideFilterBox(address: 'git.panel', hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
diff --git a/lib/builtin/output/src/output_view.dart b/lib/builtin/output/src/output_view.dart
index e60794e1..abb33111 100644
--- a/lib/builtin/output/src/output_view.dart
+++ b/lib/builtin/output/src/output_view.dart
@@ -125,7 +125,7 @@ class _OutputViewState extends State {
child: Row(
children: [
Expanded(
- child: ClideFilterBox(hint: 'Filter…', onChanged: _c.setText),
+ child: ClideFilterBox(address: 'output.panel', hint: 'Filter…', onChanged: _c.setText),
),
const SizedBox(width: 8),
_Chip(
diff --git a/lib/builtin/pql/src/pql_search_body.dart b/lib/builtin/pql/src/pql_search_body.dart
index ee3dd117..724fa0e8 100644
--- a/lib/builtin/pql/src/pql_search_body.dart
+++ b/lib/builtin/pql/src/pql_search_body.dart
@@ -103,11 +103,16 @@ class _PqlSearchBodyState extends State {
children: [
if (widget.mode == PqlPaneMode.markdown)
ClideFilterBox(
+ address: 'search.pql.markdown',
hint: 'Filter markdown…',
onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
)
else
- _PqlSearchInput(controller: c, dsl: widget.mode == PqlPaneMode.query),
+ _PqlSearchInput(
+ controller: c,
+ dsl: widget.mode == PqlPaneMode.query,
+ address: widget.mode == PqlPaneMode.query ? 'search.pql.query' : 'search.pql.vault',
+ ),
if (c.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -146,13 +151,15 @@ class _PqlSearchBodyState extends State {
}
class _PqlSearchInput extends StatelessWidget {
- const _PqlSearchInput({required this.controller, required this.dsl});
+ const _PqlSearchInput({required this.controller, required this.dsl, this.address});
final PqlController controller;
final bool dsl;
+ final String? address;
@override
Widget build(BuildContext context) {
return ClideFilterBox(
+ address: address,
hint: dsl ? 'PQL query…' : 'Search vault…',
debounce: dsl ? Duration.zero : const Duration(milliseconds: 300),
onChanged: dsl ? (_) {} : (v) => unawaited(controller.search(v)),
diff --git a/lib/builtin/problems/src/problems_view.dart b/lib/builtin/problems/src/problems_view.dart
index 1b75f2a9..065d5112 100644
--- a/lib/builtin/problems/src/problems_view.dart
+++ b/lib/builtin/problems/src/problems_view.dart
@@ -54,7 +54,7 @@ class _ProblemsViewState extends State {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- ClideFilterBox(hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)),
+ ClideFilterBox(address: 'problems.panel', hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
diff --git a/lib/builtin/search/src/search_panel_view.dart b/lib/builtin/search/src/search_panel_view.dart
index 5d4cbda2..6b6a3473 100644
--- a/lib/builtin/search/src/search_panel_view.dart
+++ b/lib/builtin/search/src/search_panel_view.dart
@@ -105,7 +105,7 @@ class _SearchPanelViewState extends State {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- ClideFilterBox(hint: 'Search', onChanged: c.run, onSubmitted: c.run),
+ ClideFilterBox(address: 'search.findInFiles', hint: 'Search', onChanged: c.run, onSubmitted: c.run),
const SizedBox(height: 6),
Row(
children: [
@@ -137,7 +137,9 @@ class _SearchPanelViewState extends State {
const SizedBox(height: 6),
Row(
children: [
- Expanded(child: ClideFilterBox(hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)),
+ Expanded(
+ child: ClideFilterBox(
+ address: 'search.findInFiles.replace', hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)),
const SizedBox(width: 6),
_ReplaceAllButton(
enabled: c.replacement.isNotEmpty && c.matchCount > 0,
@@ -147,9 +149,15 @@ class _SearchPanelViewState extends State {
],
),
const SizedBox(height: 6),
- ClideFilterBox(hint: 'files to include (e.g. *.dart)', icon: null, debounce: Duration.zero, onChanged: (v) => c.include = v),
+ ClideFilterBox(
+ address: 'search.findInFiles.include',
+ hint: 'files to include (e.g. *.dart)',
+ icon: null,
+ debounce: Duration.zero,
+ onChanged: (v) => c.include = v),
const SizedBox(height: 4),
- ClideFilterBox(hint: 'files to exclude', icon: null, debounce: Duration.zero, onChanged: (v) => c.exclude = v),
+ ClideFilterBox(
+ address: 'search.findInFiles.exclude', hint: 'files to exclude', icon: null, debounce: Duration.zero, onChanged: (v) => c.exclude = v),
],
),
),
diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart
index 0f2df85f..e122c884 100644
--- a/lib/builtin/tickets/src/tickets_view.dart
+++ b/lib/builtin/tickets/src/tickets_view.dart
@@ -159,7 +159,7 @@ class _TicketsViewState extends State {
children: [
Row(
children: [
- Expanded(child: ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))),
+ Expanded(child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))),
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideTappable(
diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart
index 6dd1657f..27adcf5f 100644
--- a/lib/kernel/kernel.dart
+++ b/lib/kernel/kernel.dart
@@ -12,6 +12,7 @@
library;
export 'src/events/bus.dart';
+export 'src/events/filter_state.dart';
export 'src/events/message_bus.dart';
export 'src/events/types.dart';
export 'src/ipc/client.dart';
diff --git a/lib/kernel/src/events/filter_state.dart b/lib/kernel/src/events/filter_state.dart
new file mode 100644
index 00000000..5d85bda2
--- /dev/null
+++ b/lib/kernel/src/events/filter_state.dart
@@ -0,0 +1,36 @@
+import 'dart:async';
+
+import 'message_bus.dart';
+
+/// Caches the latest filter value per address, fed by `filter.state`
+/// messages on the [MessageBus] (T-270, D-6 parity).
+///
+/// Sidebar filter boxes are addressable from the CLI: `clide ui filter`
+/// with an address and text publishes a `filter.set` message the box
+/// reacts to (the drive-half). The observe-half — the same verb with no
+/// text — needs to read the box's *current* value back, but the bus is
+/// a plain broadcast stream with no retention. Each box republishes its
+/// value on the `filter.state` channel whenever it changes; this cache
+/// listens once and remembers the latest per address, giving the observe
+/// verb something to read.
+///
+/// Pure Dart — no Flutter import — so it serialises through the kernel and
+/// stays usable from `dart test`.
+class FilterStateCache {
+ FilterStateCache({required MessageBus messages}) {
+ _sub = messages.subscribe(channel: 'filter.state').listen((m) {
+ _values[m.publisher] = m.data['query'] as String? ?? '';
+ });
+ }
+
+ final Map _values = {};
+ StreamSubscription? _sub;
+
+ /// The last reported filter value for [address], or null if no box at
+ /// that address has reported yet.
+ String? get(String address) => _values[address];
+
+ void dispose() {
+ _sub?.cancel();
+ }
+}
diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart
index acffab97..18f36c1f 100644
--- a/lib/kernel/src/facade.dart
+++ b/lib/kernel/src/facade.dart
@@ -10,6 +10,7 @@ import 'package:clide/kernel/src/commands/palette.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/dialog.dart';
import 'package:clide/kernel/src/events/bus.dart';
+import 'package:clide/kernel/src/events/filter_state.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/kernel/src/extensions_manager.dart';
import 'package:clide/kernel/src/files.dart';
@@ -47,6 +48,7 @@ class KernelServices {
required this.settings,
required this.events,
required this.messages,
+ required this.filterStates,
required this.ipc,
required this.theme,
required this.i18n,
@@ -82,6 +84,10 @@ class KernelServices {
final SettingsStore settings;
final DaemonBus events;
final MessageBus messages;
+
+ /// Latest filter value per addressable box, fed by `filter.state`
+ /// messages — backs the observe-half of `clide ui filter` (T-270).
+ final FilterStateCache filterStates;
final DaemonClient ipc;
final ThemeController theme;
final I18n i18n;
@@ -135,6 +141,7 @@ class KernelServices {
final log = Logger(sinks: [stderrSink, logRing.add]);
final events = sharedBus ?? DaemonBus();
final messages = MessageBus();
+ final filterStates = FilterStateCache(messages: messages);
final settings = SettingsStore(appDir: appDir);
await settings.load();
@@ -236,6 +243,7 @@ class KernelServices {
settings: settings,
events: events,
messages: messages,
+ filterStates: filterStates,
ipc: ipc,
theme: theme,
i18n: i18n,
@@ -292,6 +300,7 @@ class KernelServices {
keymap.dispose();
textZoom.dispose();
await log.dispose();
+ filterStates.dispose();
messages.dispose();
await events.dispose();
}
diff --git a/lib/main.dart b/lib/main.dart
index 390efc5c..3d362efe 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -98,6 +98,9 @@ Future main() async {
// The kernel MessageBus, captured post-boot so `ui.open` can drive the GUI
// readers (publish a 'selection') from the CLI — the drive-half of D-6 (T-231).
MessageBus? kernelMessages;
+ // The filter-state cache, captured post-boot so `ui.filter` can read a
+ // box's current value back — the observe-half of D-6 (T-270).
+ FilterStateCache? kernelFilterStates;
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per
// workspace; restarted when the active project switches because the
// socket path is workspace-derived. The local DaemonClient connects
@@ -216,7 +219,11 @@ Future main() async {
// `clide ui open ` — drive the GUI readers from the CLI
// (T-231, drive-half of D-6). Publishes a 'selection' to the kernel
// MessageBus, captured post-boot; null in headless contexts.
- registerUiCommands(dispatcher, () => kernelMessages?.publish);
+ registerUiCommands(
+ dispatcher,
+ () => kernelMessages?.publish,
+ filterValue: (address) => kernelFilterStates?.get(address),
+ );
// `clide image show ` — drive an image card into the Claude
// conversation log (T-249, drive-half of D-6). Resolves the path
// (workspace-relative → absolute, must exist) here where workRoot is in
@@ -340,6 +347,7 @@ Future main() async {
// only reads it at request time (post-boot), so capturing it here is safe.
kernelReaderNav = services.readerNav;
kernelMessages = services.messages;
+ kernelFilterStates = services.filterStates;
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
// shows socket-side logs alongside kernel/extension ones.
ipcLog.addSink(services.logRing.add);
diff --git a/lib/src/daemon/ui_command.dart b/lib/src/daemon/ui_command.dart
index 16f07556..f44d19a2 100644
--- a/lib/src/daemon/ui_command.dart
+++ b/lib/src/daemon/ui_command.dart
@@ -1,4 +1,5 @@
-/// Registers `ui.open` — the drive-half of D-6 parity (T-231).
+/// Registers the `ui.*` drive/observe verbs — the parity peers of D-6
+/// (`ui.open` T-231, `ui.toast` T-50, `ui.filter` T-270).
///
/// Epic C (T-218) gave the CLI the *observe* half: `clide status` / `pane
/// list` read live UI state. This is the complement — the agent asks the
@@ -16,6 +17,7 @@
/// Flutter-free and runs under `dart test`.
library;
+import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import 'dispatcher.dart';
@@ -40,9 +42,26 @@ const Map _readers = {
/// literal so this file stays Flutter-free for `dart test`).
const Set _toastSeverities = {'success', 'warning', 'error', 'info'};
-void registerUiCommands(DaemonDispatcher d, MessagePublisher? Function() publisher) {
+/// Reads the current filter value for an addressable box from the kernel's
+/// `FilterStateCache` — the observe-half of `ui.filter`. Returns null when
+/// nothing has reported a value for that address (or there is no live UI).
+typedef FilterValueSource = String? Function(String address);
+
+void registerUiCommands(
+ DaemonDispatcher d,
+ MessagePublisher? Function() publisher, {
+ FilterValueSource? filterValue,
+}) {
d.register('ui.open', (req) async => _open(req, publisher));
d.register('ui.toast', (req) async => _toast(req, publisher));
+ d.register(
+ 'ui.filter',
+ (req) async => _filter(req, publisher, filterValue),
+ schema: const CommandSchema(
+ positional: ['address', 'query'],
+ args: {'address': ArgSpec(required: true), 'query': ArgSpec()},
+ ),
+ );
}
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
@@ -122,3 +141,50 @@ Future _toast(IpcRequest req, MessagePublisher? Function() publishe
});
return IpcResponse.ok(id: req.id, data: {'message': message, 'severity': severity, 'shown': true});
}
+
+/// `clide ui filter []` — the drive+observe parity peer for
+/// the sidebar filter boxes (T-270, D-6). Routed through the MessageBus so a
+/// box reacts identically whether the trigger was a UI keystroke or this
+/// verb, keeping extensions first-class (no dispatcher→widget wiring).
+///
+/// clide ui filter decisions.panel git # set the filter to "git"
+/// clide ui filter decisions.panel "" # clear it
+/// clide ui filter decisions.panel # read the current value
+///
+/// `address` is a pane/box id from `clide pane list`. With a `query` arg the
+/// verb *drives* — publishes a `filter.set` the box consumes. Without one it
+/// *observes* — reads the box's last reported value from the FilterStateCache.
+Future _filter(
+ IpcRequest req,
+ MessagePublisher? Function() publisherSource,
+ FilterValueSource? filterValue,
+) async {
+ final address = req.args['address'] as String?;
+ if (address == null || address.isEmpty) {
+ return _userErr(req.id, 'an address is required', hint: 'a pane/box id from `clide pane list` (e.g. decisions.panel)');
+ }
+
+ // Absent query → observe; present (even empty string) → drive. The schema
+ // drops unmapped positionals, so a missing query leaves no `query` key.
+ if (!req.args.containsKey('query')) {
+ final getter = filterValue;
+ if (getter == null) {
+ return IpcResponse.err(
+ id: req.id,
+ error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to observe (clide is not running a GUI)'),
+ );
+ }
+ return IpcResponse.ok(id: req.id, data: {'address': address, 'query': getter(address)});
+ }
+
+ final query = (req.args['query'] as String?) ?? '';
+ 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)'),
+ );
+ }
+ publish(address, 'filter.set', {'query': query});
+ return IpcResponse.ok(id: req.id, data: {'address': address, 'query': query, 'set': true});
+}
diff --git a/lib/widgets/src/clide_filter_box.dart b/lib/widgets/src/clide_filter_box.dart
index 01cfd356..f99c490e 100644
--- a/lib/widgets/src/clide_filter_box.dart
+++ b/lib/widgets/src/clide_filter_box.dart
@@ -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 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 ` drives it exactly as a UI
+ /// keystroke would — and republishes its value on `filter.state` so the
+ /// observe-half (`clide ui filter `) 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 createState() => _ClideFilterBoxState();
}
@@ -33,18 +45,55 @@ class _ClideFilterBoxState extends State {
final _controller = TextEditingController();
final _focus = FocusNode();
Timer? _debounceTimer;
+ StreamSubscription? _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 {
_controller.clear();
_debounceTimer?.cancel();
widget.onChanged('');
+ _publishState('');
_focus.requestFocus();
setState(() {});
}
diff --git a/test/daemon/ui_command_test.dart b/test/daemon/ui_command_test.dart
index 1ca60a4e..82dafa4f 100644
--- a/test/daemon/ui_command_test.dart
+++ b/test/daemon/ui_command_test.dart
@@ -12,13 +12,22 @@ void main() {
late List<({String publisher, String channel, Map 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 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 open(List 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 filter(List positional) => d.dispatch(
+ IpcRequest(id: '1', cmd: 'ui.filter', args: {'positional': positional}),
+ );
+
+ test('ui filter 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 "" 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 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);
+ });
}
diff --git a/test/kernel/filter_state_test.dart b/test/kernel/filter_state_test.dart
new file mode 100644
index 00000000..056f06c1
--- /dev/null
+++ b/test/kernel/filter_state_test.dart
@@ -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 settle() => Future.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);
+ });
+}
diff --git a/test/widgets/src/clide_filter_box_test.dart b/test/widgets/src/clide_filter_box_test.dart
new file mode 100644
index 00000000..73bd0cbe
--- /dev/null
+++ b/test/widgets/src/clide_filter_box_test.dart
@@ -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 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(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');
+ });
+}