From be06b947542a9034a04ae3310f900ef87537a33f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 3 Jun 2026 12:49:57 +0200 Subject: [PATCH] register live GUI panes into clide pane list (T-219) Per D-6 parity / D-83, make the kernel tabs the user sees (Claude, Files, Editor, viewers) visible to the CLI. The PTY-backed PaneRegistry can't model widget panes, so rather than mirror state (and risk drift), pane.list snapshots the kernel PanelRegistry + LayoutArrangement at request time via an injected view-pane source. New Flutter-free ViewPane value type + snapshotViewPanes kernel bridge; the daemonClientFactory now passes the PanelRegistry through so the dispatcher can read it. pane.list merges PTY panes (source absent) with UI tabs (source: ui, with slot/title/active/visible). Acceptance (GUI-open: pane list enumerates live panes) needs a running app to confirm end-to-end; unit-tested at the snapshot + dispatch level. Closes T-219 (under T-218 / T-208 'Give Claude hands'). Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 3 + .pql/changelog/tickets/2026-06.sql | 19 +++++ CHANGELOG.md | 4 ++ integration_test/app_starts_test.dart | 2 +- .../extension_lifecycle_test.dart | 2 +- integration_test/theme_picker_test.dart | 2 +- lib/kernel/kernel.dart | 1 + lib/kernel/src/facade.dart | 4 +- lib/kernel/src/panels/view_pane_snapshot.dart | 33 +++++++++ lib/main.dart | 21 ++++-- lib/src/daemon/pane_commands.dart | 19 +++-- lib/src/panes/view_pane.dart | 48 +++++++++++++ test/daemon/pane_commands_test.dart | 50 ++++++++++++++ test/helpers/kernel_fixture.dart | 2 +- .../src/panels/view_pane_snapshot_test.dart | 69 +++++++++++++++++++ 15 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 lib/kernel/src/panels/view_pane_snapshot.dart create mode 100644 lib/src/panes/view_pane.dart create mode 100644 test/kernel/src/panels/view_pane_snapshot_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 36d011fa..fa2aa8d1 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -76,3 +76,6 @@ 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-228', 'status', 'in_progress', 'done', NULL, '2026-06-03 10:02:02', '2026-06-03 10:02:02', '2026-06-03 10:02:02', NULL, 'de2818775d8d462446c341b2e74aed2e', 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-163', 'status', 'in_progress', 'done', NULL, '2026-06-03 10:05:55', '2026-06-03 10:05:55', '2026-06-03 10:05:55', NULL, '36b6f761d7005750a192f2f4aa5e60b4', 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-227', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 10:05:55', '2026-06-03 10:05:55', '2026-06-03 10:05:55', NULL, 'ec0f5acabfaacca93fb1547bb2eb4280', 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-227', 'status', 'in_progress', 'done', NULL, '2026-06-03 10:09:26', '2026-06-03 10:09:26', '2026-06-03 10:09:26', NULL, '4fab6a35e4135bbaf278cd094822f1ea', 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-229', 'status', 'backlog', 'done', NULL, '2026-06-03 10:09:26', '2026-06-03 10:09:26', '2026-06-03 10:09:26', NULL, 'a0e3cc0b5550fe7d408cdbda7675b525', 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-219', 'status', 'in_progress', 'done', NULL, '2026-06-03 10:31:10', '2026-06-03 10:31:10', '2026-06-03 10:31:10', NULL, '6a980de3525b2d933850f49854bc5202', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 90ccbcfe..5e2bae38 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -169,3 +169,22 @@ Acceptance: 4. Interaction-zone nuance (D-78): when a permission / AskUserQuestion prompt has replaced the composer in the interaction zone, a background click should focus that prompt''s primary control instead of the (absent) composer — or do nothing — never resurrect the composer. Implementation sketch: wrap the pane body in a tap recognizer (behavior: translucent / deferToChild) that, on a tap that did not hit a child gesture and produced no text selection, requests focus on the composer FocusNode. Mind the SelectableRegion so selection drags aren''t hijacked.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-03 09:48:23', '2026-06-03 10:05:55', NULL, 'f8cede4c62e453b421cae5ba9a54af2b', 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-227', 'story', 'T-229', 'Click empty Claude-pane area to focus the composer', 'Clicking anywhere in the Claude pane that is NOT an interactive control (copy button, approve/reject, AskUserQuestion options, links, selectable transcript text, scrollbars, tabs) should move keyboard focus to the composer text field at the bottom. Today focusing the composer requires precisely clicking the field; a click on the conversation background does nothing, so the input is fiddly to focus. + +Goal: make the composer easy to focus — a click on dead space in the pane lands the cursor in the input, like clicking an empty area of a chat app focuses its reply box. + +Acceptance: +1. A tap on non-interactive Claude-pane background focuses the composer input (cursor ready to type). +2. Taps on actual controls keep their behavior (copy, approve/reject, AskUserQuestion choices, tab strip, links) — the focus-grab must not swallow or pre-empt their gestures. +3. Text selection in the transcript still works: a click-drag to select must NOT steal focus to the composer (only a plain tap/click-release with no selection focuses it). +4. Interaction-zone nuance (D-78): when a permission / AskUserQuestion prompt has replaced the composer in the interaction zone, a background click should focus that prompt''s primary control instead of the (absent) composer — or do nothing — never resurrect the composer. + +Implementation sketch: wrap the pane body in a tap recognizer (behavior: translucent / deferToChild) that, on a tap that did not hit a child gesture and produced no text selection, requests focus on the composer FocusNode. Mind the SelectableRegion so selection drags aren''t hijacked.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 09:48:23', '2026-06-03 10:09:26', NULL, '173f374747e44ab8b989e68e1cace427', 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-229', 'epic', NULL, 'Claude composer ergonomics: focus, persistence, and history', 'Make the Claude composer (the prompt input at the bottom of the Claude pane) feel effortless to use. Groups the input-box ergonomics work: + +- T-227 — click empty pane area to focus the composer +- T-228 — preserve the in-progress draft across UI changes (permission prompts etc.) +- T-163 — Up/Down arrow recall of prompt history (Claude-CLI-style), triggered when the caret hits the top/bottom edge of the field + +Theme: the composer should be easy to focus, never lose what you typed, and let you recall and re-send prior prompts the way the Claude CLI does. These are independent leaves that share the composer surface and per-session draft/history state, so they''re worth coordinating.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 09:51:28', '2026-06-03 10:09:26', NULL, '844cfc2043d37af092a0cde6a31f619c', 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-219', 'task', 'T-218', 'Register built-in panes into the PaneRegistry the CLI reads', 'Make the kernel layout''s TabContribution panes (Claude / Files / Editor and any viewer) visible to `pane list` / `pane focus` by registering or mirroring them into PaneRegistry (lib/src/panes/registry.dart, read by pane_commands.dart:121). Acceptance: with the GUI open, `clide pane list` enumerates the live panes the user sees, with stable ids and focus state.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 10:31:10', NULL, 'c61622fb5b14bed734e8a3d8dc096be4', 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 0f41028c..cf90b92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- `clide pane list` now reflects the live GUI tabs the user sees (Claude, Files, + Editor, viewers) alongside PTY panes — each with a stable id, slot, title, and + active/visible state — by snapshotting the kernel panel layout at request time. + Restores the D-6 "agent sees what the user sees" half of parity. (T-219, D-83) - Click empty Claude-pane area to focus the composer: a tap on conversation dead space lands the cursor in the input. Message controls and transcript text-selection are unaffected, and it stays inert while a prompt occupies the diff --git a/integration_test/app_starts_test.dart b/integration_test/app_starts_test.dart index a47370c1..72f99df1 100644 --- a/integration_test/app_starts_test.dart +++ b/integration_test/app_starts_test.dart @@ -49,7 +49,7 @@ void main() { 'builtin.theme-picker', 'builtin.default-layout', ], - daemonClientFactory: (log, events, _) => FakeDaemonClient(log: log, events: events), + daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events), autoStartDaemonClient: false, ); services.extensions diff --git a/integration_test/extension_lifecycle_test.dart b/integration_test/extension_lifecycle_test.dart index ea96bcac..80f7215f 100644 --- a/integration_test/extension_lifecycle_test.dart +++ b/integration_test/extension_lifecycle_test.dart @@ -40,7 +40,7 @@ void main() { 'builtin.ipc-status', 'builtin.default-layout', ], - daemonClientFactory: (log, events, _) => FakeDaemonClient(log: log, events: events), + daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events), autoStartDaemonClient: false, ); services.extensions diff --git a/integration_test/theme_picker_test.dart b/integration_test/theme_picker_test.dart index 560ec7a7..dff1791d 100644 --- a/integration_test/theme_picker_test.dart +++ b/integration_test/theme_picker_test.dart @@ -31,7 +31,7 @@ void main() { 'builtin.theme-picker', 'builtin.default-layout', ], - daemonClientFactory: (log, events, _) => FakeDaemonClient(log: log, events: events), + daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events), autoStartDaemonClient: false, ); services.extensions diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index cb4fe8ba..c728827f 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -52,6 +52,7 @@ export 'src/panels/drag_resize.dart'; export 'src/panels/layout_preset.dart'; export 'src/panels/registry.dart'; export 'src/panels/slot_id.dart'; +export 'src/panels/view_pane_snapshot.dart'; export 'src/theme/contrast.dart'; export 'src/theme/controller.dart'; export 'src/theme/loader.dart'; diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 4519b069..8a346cc2 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -115,7 +115,7 @@ class KernelServices { Locale? initialLocale, List availableLocales = const [Locale('en', 'US')], String? socketPath, - DaemonClient Function(Logger, DaemonBus, LayoutArrangement)? daemonClientFactory, + DaemonClient Function(Logger, DaemonBus, LayoutArrangement, PanelRegistry)? daemonClientFactory, DaemonClient? isolateClient, bool autoStartDaemonClient = true, Toolchain? toolchain, @@ -176,7 +176,7 @@ class KernelServices { ); final ipc = isolateClient ?? (daemonClientFactory != null - ? daemonClientFactory(log, events, arrangement) + ? daemonClientFactory(log, events, arrangement, panels) : DaemonClient( // Legacy socket-client fallback — kept until T-127 // replaces it with the in-process socket loopback. diff --git a/lib/kernel/src/panels/view_pane_snapshot.dart b/lib/kernel/src/panels/view_pane_snapshot.dart new file mode 100644 index 00000000..2cf3b896 --- /dev/null +++ b/lib/kernel/src/panels/view_pane_snapshot.dart @@ -0,0 +1,33 @@ +/// Snapshots the kernel's live tabs into [ViewPane]s so `pane list` reflects +/// the panes the user actually sees in the GUI (T-219, D-6 parity / D-83). +/// +/// Read-at-request-time: no state is mirrored into the IPC [PaneRegistry], so +/// nothing can drift from the live UI. Lives in the kernel (not `lib/src/panes/`) +/// because it reads Flutter-coupled kernel state; it produces the Flutter-free +/// [ViewPane] the pane command serialises. +library; + +import 'package:clide/kernel/src/panels/arrangement.dart'; +import 'package:clide/kernel/src/panels/registry.dart'; +import 'package:clide/src/panes/view_pane.dart'; + +/// Build a [ViewPane] for every tab in every slot the [panels] registry knows, +/// tagging the active tab per slot and whether its slot is currently visible +/// (read from [arrangement]). +List snapshotViewPanes(PanelRegistry panels, LayoutArrangement arrangement) { + final out = []; + for (final slot in panels.slots) { + final activeId = panels.activeTabIn(slot.id); + final visible = arrangement.isVisible(slot.id); + for (final tab in panels.tabsFor(slot.id)) { + out.add(ViewPane( + id: tab.id, + slot: slot.id.value, + title: tab.title, + active: tab.id == activeId, + visible: visible, + )); + } + } + return out; +} diff --git a/lib/main.dart b/lib/main.dart index 40067947..527e5e7a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -87,6 +87,7 @@ Future main() async { DaemonClient? ipcClient; DaemonBus? daemonBus; LayoutArrangement? kernelArrangement; + PanelRegistry? kernelPanels; // 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 @@ -162,11 +163,19 @@ Future main() async { Toolchain tc, Directory workRoot, LayoutArrangement arrangement, + PanelRegistry panels, ) { final dispatcher = DaemonDispatcher(); final eventSink = _BusEventSink(events); final paneRegistry = PaneRegistry(events: eventSink); - registerPaneCommands(dispatcher, paneRegistry); + // D-6 parity (T-219, D-83): make the tabs the user sees in the GUI + // visible to `pane list` by snapshotting the kernel PanelRegistry + + // LayoutArrangement at request time — no mirrored state to drift. + registerPaneCommands( + dispatcher, + paneRegistry, + viewPanes: () => snapshotViewPanes(panels, arrangement), + ); // Trusted read-only roots beyond the workspace: the global Claude // config dir (~/.claude), so the reader can open user-scope skill / // agent / command markdown the Config tab surfaces (D-80, T-195). @@ -207,11 +216,12 @@ Future main() async { toolchain: toolchain, daemonClientFactory: kIsWeb ? null - : (log, events, arrangement) { + : (log, events, arrangement, panels) { daemonBus = events; kernelArrangement = arrangement; + kernelPanels = panels; final workRoot = FilesService.atCwd(events: _BusEventSink(events)).root; - final dispatcher = buildDispatcher(events, toolchain, workRoot, arrangement); + final dispatcher = buildDispatcher(events, toolchain, workRoot, arrangement, panels); // Build the client at the workspace's socket path. The // server is started below (swapIpcServer) which the // client will then auto-connect to via its reconnect @@ -238,8 +248,9 @@ Future main() async { : (path) async { final bus = daemonBus; final arrangement = kernelArrangement; - if (bus == null || arrangement == null) return; - final dispatcher = buildDispatcher(bus, toolchain, Directory(path), arrangement); + final panels = kernelPanels; + if (bus == null || arrangement == null || panels == null) return; + final dispatcher = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels); await swapIpcServer(dispatcher, Directory(path)); }, ); diff --git a/lib/src/daemon/pane_commands.dart b/lib/src/daemon/pane_commands.dart index 1f4b8d0d..3b00bba9 100644 --- a/lib/src/daemon/pane_commands.dart +++ b/lib/src/daemon/pane_commands.dart @@ -16,12 +16,19 @@ import '../ipc/errno_mapping.dart'; import '../ipc/schema_v1.dart'; import '../panes/pane.dart'; import '../panes/registry.dart'; +import '../panes/view_pane.dart'; import '../pty/errors.dart'; import 'dispatcher.dart'; -void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) { +/// Snapshots the non-PTY panes the user sees in the GUI (kernel tabs) at +/// request time — see [ViewPane]. Null in headless contexts (tests, the +/// CLI-only path) where there is no live UI; then `pane.list` reports only +/// PTY panes, exactly as before (T-219, D-6 parity / D-83). +typedef ViewPaneSource = List Function(); + +void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {ViewPaneSource? viewPanes}) { d.register('pane.spawn', (req) => _spawn(req, registry)); - d.register('pane.list', (req) => _list(req, registry)); + d.register('pane.list', (req) => _list(req, registry, viewPanes)); d.register('pane.close', (req) => _close(req, registry)); d.register('pane.write', (req) => _write(req, registry)); d.register('pane.resize', (req) => _resize(req, registry)); @@ -118,11 +125,15 @@ Future _spawn(IpcRequest req, PaneRegistry registry) async { } } -Future _list(IpcRequest req, PaneRegistry registry) async { +Future _list(IpcRequest req, PaneRegistry registry, ViewPaneSource? viewPanes) async { return IpcResponse.ok( id: req.id, data: { - 'panes': [for (final p in registry.panes) p.toJson()] + 'panes': [ + for (final p in registry.panes) p.toJson(), + if (viewPanes != null) + for (final v in viewPanes()) v.toJson(), + ] }, ); } diff --git a/lib/src/panes/view_pane.dart b/lib/src/panes/view_pane.dart new file mode 100644 index 00000000..29910a48 --- /dev/null +++ b/lib/src/panes/view_pane.dart @@ -0,0 +1,48 @@ +/// A non-PTY pane the user sees in the GUI — a kernel tab (Claude, Files, +/// Editor, a viewer) surfaced so `pane list` reflects the live workspace, +/// not only PTY-spawned panes (T-219, D-6 parity / D-83). +/// +/// The PTY-backed [Pane] can't represent these: a tab has no child process. +/// Rather than mirror state into [PaneRegistry] (and risk it drifting from +/// the live UI), the kernel snapshots its `PanelRegistry` into these value +/// objects at request time — `pane.list` reads them through an injected +/// source. Pure data, Flutter-free, so it serialises on the IPC wire and +/// stays usable from `dart test`. +library; + +class ViewPane { + const ViewPane({ + required this.id, + required this.slot, + required this.title, + required this.active, + required this.visible, + }); + + /// Stable contribution id (e.g. `claude`, `files`, `editor`) — the same id + /// `pane.focus` would target. + final String id; + + /// The slot the tab lives in (`sidebar` / `workspace` / `context`). + final String slot; + + /// Display title the user sees on the tab. + final String title; + + /// Whether this is the active (front) tab in its slot — the focus state + /// the acceptance asks for. + final bool active; + + /// Whether the tab's slot is currently visible (not collapsed/hidden). + final bool visible; + + Map toJson() => { + 'id': id, + 'kind': 'view', + 'slot': slot, + 'title': title, + 'active': active, + 'visible': visible, + 'source': 'ui', + }; +} diff --git a/test/daemon/pane_commands_test.dart b/test/daemon/pane_commands_test.dart index a13b223f..5d368f3c 100644 --- a/test/daemon/pane_commands_test.dart +++ b/test/daemon/pane_commands_test.dart @@ -11,6 +11,7 @@ import 'dart:io'; import 'package:clide/clide.dart'; import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/panes/registry.dart'; +import 'package:clide/src/panes/view_pane.dart'; import 'package:test/test.dart'; void main() { @@ -193,4 +194,53 @@ void main() { expect(unknown.error!.kind, 'not_found'); }); }); + + // T-219 / D-83: `pane list` reflects the GUI tabs the user sees, merged + // with the PTY panes, via an injected view-pane source. + group('pane.list view-pane merge (T-219)', () { + late DaemonDispatcher dispatcher; + late PaneRegistry registry; + + setUp(() { + registry = PaneRegistry(events: RecordingEventSink()); + dispatcher = DaemonDispatcher(); + registerPaneCommands( + dispatcher, + registry, + viewPanes: () => const [ + ViewPane(id: 'claude', slot: 'workspace', title: 'Claude', active: true, visible: true), + ViewPane(id: 'files', slot: 'sidebar', title: 'Files', active: false, visible: true), + ], + ); + }); + + tearDown(() => registry.shutdown()); + + Future call(String cmd, Map args) => dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args)); + + test('lists the live UI tabs with stable ids, slot, title, focus state', () async { + final r = await call('pane.list', const {}); + final panes = (r.data['panes'] as List).cast(); + expect(panes, hasLength(2)); + final claude = panes.firstWhere((p) => p['id'] == 'claude'); + expect(claude['source'], 'ui'); + expect(claude['kind'], 'view'); + expect(claude['slot'], 'workspace'); + expect(claude['title'], 'Claude'); + expect(claude['active'], isTrue); + expect(panes.firstWhere((p) => p['id'] == 'files')['active'], isFalse); + }); + + test('merges PTY panes and UI tabs in one list', () async { + await call('pane.spawn', { + 'argv': const ['/bin/cat'] + }); + final r = await call('pane.list', const {}); + final panes = (r.data['panes'] as List).cast(); + // one PTY pane (source absent) + two UI tabs (source: ui). + expect(panes, hasLength(3)); + expect(panes.where((p) => p['source'] == 'ui'), hasLength(2)); + expect(panes.where((p) => p['id'].toString().startsWith('p_')), hasLength(1)); + }); + }); } diff --git a/test/helpers/kernel_fixture.dart b/test/helpers/kernel_fixture.dart index e68a3e02..f39933bd 100644 --- a/test/helpers/kernel_fixture.dart +++ b/test/helpers/kernel_fixture.dart @@ -33,7 +33,7 @@ class KernelFixture { preloadNamespaces: preloadNamespaces ?? catalogs.keys.toList(), defaultLocale: defaultLocale, initialLocale: initialLocale, - daemonClientFactory: (log, events, _) { + daemonClientFactory: (log, events, _, __) { fake = FakeDaemonClient(log: log, events: events); return fake!; }, diff --git a/test/kernel/src/panels/view_pane_snapshot_test.dart b/test/kernel/src/panels/view_pane_snapshot_test.dart new file mode 100644 index 00000000..97415245 --- /dev/null +++ b/test/kernel/src/panels/view_pane_snapshot_test.dart @@ -0,0 +1,69 @@ +/// Tests for [snapshotViewPanes] — the kernel→ViewPane bridge that lets +/// `pane list` reflect the GUI tabs the user sees (T-219, D-83). +library; + +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +TabContribution _tab(String id, SlotId slot) => TabContribution( + id: id, + slot: slot, + title: id.toUpperCase(), + build: (_) => const SizedBox.shrink(), + ); + +void main() { + group('snapshotViewPanes', () { + late PanelRegistry panels; + late LayoutArrangement arrangement; + + setUp(() { + panels = PanelRegistry(); + arrangement = LayoutArrangement(); + panels.registerSlot(const SlotDefinition(id: Slots.workspace, position: SlotPosition.center)); + panels.registerSlot(const SlotDefinition(id: Slots.sidebar, position: SlotPosition.left)); + }); + + test('one ViewPane per tab, tagged with slot, title and active state', () { + panels.contribute(_tab('claude', Slots.workspace)); + panels.contribute(_tab('editor', Slots.workspace)); + panels.contribute(_tab('files', Slots.sidebar)); + + final panesById = {for (final v in snapshotViewPanes(panels, arrangement)) v.id: v}; + expect(panesById.keys, containsAll(['claude', 'editor', 'files'])); + expect(panesById['claude']!.slot, 'workspace'); + expect(panesById['claude']!.title, 'CLAUDE'); + // first tab in a slot is the active one + expect(panesById['claude']!.active, isTrue); + expect(panesById['editor']!.active, isFalse); + expect(panesById['files']!.active, isTrue); + }); + + test('active follows the kernel activeTab; visible follows the arrangement', () { + arrangement.applyPreset(const LayoutPresetContribution( + id: 'test', + displayName: 'Test', + slots: [LayoutSlot(slot: Slots.workspace, position: SlotPosition.center, visible: true)], + )); + panels.contribute(_tab('claude', Slots.workspace)); + panels.contribute(_tab('editor', Slots.workspace)); + panels.activateTab(Slots.workspace, 'editor'); + + var panesById = {for (final v in snapshotViewPanes(panels, arrangement)) v.id: v}; + expect(panesById['editor']!.active, isTrue); + expect(panesById['claude']!.active, isFalse); + expect(panesById['editor']!.visible, isTrue); + + // Hiding the slot is reflected on the next snapshot (read-at-request). + arrangement.setVisible(Slots.workspace, false); + panesById = {for (final v in snapshotViewPanes(panels, arrangement)) v.id: v}; + expect(panesById['editor']!.visible, isFalse); + }); + + test('empty when no tabs are contributed', () { + expect(snapshotViewPanes(panels, arrangement), isEmpty); + }); + }); +}