From 49f480481aa4886aaf8394c7fe15efea3482bc16 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 7 Jun 2026 09:58:29 +0200 Subject: [PATCH] =?UTF-8?q?wire=20the=20output=20dock=20into=20the=20layou?= =?UTF-8?q?t=20=E2=80=94=20toggle,=20tabs,=20persistence=20(T-54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D-87 bottom dock, end to end. New Slots.dock in the classic preset (hidden by default); RootLayout renders it full-width above the status bar when open, capped at half the window so Claude stays largest (the D-47 amendment). LogRing now lives on KernelServices (boot tees the kernel logger into it; main.dart also tees the IPC/MCP logger), so the dock shows logs from every subsystem. OutputExtension contributes the Output tab, the merged health/toggle status-bar widget (green check when clean, warn/error counts otherwise) that replaces the old ipc-status item, and the dock.toggle command (Ctrl+J). Problems moves out of the sidebar into the dock. open/height persist per workspace via the default-layout extension. Drag-resize of the dock height is deferred (DragResizeHandle needs a dock sign case); height is the persisted default for now. Boot verified via testmode; full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++ lib/app.dart | 14 +++ lib/builtin/default_layout/src/extension.dart | 8 ++ lib/builtin/output/output.dart | 2 + lib/builtin/output/src/dock_status_item.dart | 88 +++++++++++++++++++ lib/builtin/output/src/extension.dart | 63 +++++++++++++ lib/builtin/problems/src/extension.dart | 6 +- lib/kernel/src/facade.dart | 9 +- lib/kernel/src/panels/layout_preset.dart | 8 ++ lib/kernel/src/panels/slot_id.dart | 4 + lib/main.dart | 7 +- .../builtin/output/dock_status_item_test.dart | 52 +++++++++++ .../builtin/output/output_extension_test.dart | 42 +++++++++ 13 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 lib/builtin/output/src/dock_status_item.dart create mode 100644 lib/builtin/output/src/extension.dart create mode 100644 test/builtin/output/dock_status_item_test.dart create mode 100644 test/builtin/output/output_extension_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb76854..fc63dd9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- A bottom **output dock**: toggle it from a status-bar widget (or `⌘J`/`Ctrl+J`) + to see logs (Output) and diagnostics (Problems) as tabs — filterable by + source/level/text, auto-scrolling. The status widget doubles as a health + badge (green `✓` clean, `⚠`/`✕` counts otherwise) and replaces the old + app-status item; Problems moved here from the sidebar. (T-54, D-87) - External MCP clients (Cursor, Windsurf, Copilot, …) can now drive clide: the MCP server exposes the full `mcp__clide__*` tool surface, generated from the command registry that already feeds the CLI + palette (D-86), with a diff --git a/lib/app.dart b/lib/app.dart index bff96263..86b80923 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -211,6 +211,12 @@ class RootLayout extends StatelessWidget { final sidebarSize = a.sizeOf(Slots.sidebar) ?? 400; final contextSize = a.sizeOf(Slots.contextPanel) ?? 420; final statusHeight = a.sizeOf(Slots.statusbar) ?? 26; + // Bottom output dock (T-54 / D-87): pushes the workspace up when open, + // capped at half the window so Claude stays the largest surface (the + // D-47 amendment). + final dockVisible = a.isVisible(Slots.dock); + final dockMax = (((MediaQuery.of(ctx).size.height) - statusHeight) * 0.5).clamp(80.0, double.infinity).toDouble(); + final dockHeight = dockVisible ? ((a.sizeOf(Slots.dock) ?? 200).clamp(0.0, dockMax)).toDouble() : 0.0; return Column( children: [ @@ -255,6 +261,14 @@ class RootLayout extends StatelessWidget { ], ), ), + if (dockVisible) + SizedBox( + height: dockHeight, + child: DecoratedBox( + decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder))), + child: SlotHost(slot: Slots.dock), + ), + ), if (statusVisible) Container( height: statusHeight, diff --git a/lib/builtin/default_layout/src/extension.dart b/lib/builtin/default_layout/src/extension.dart index 9f281942..8fb8f7d9 100644 --- a/lib/builtin/default_layout/src/extension.dart +++ b/lib/builtin/default_layout/src/extension.dart @@ -144,6 +144,10 @@ class DefaultLayoutExtension extends ClideExtension { if (sidebarSize != null) ctx.arrangement.setSize(Slots.sidebar, sidebarSize); final contextSize = s.get(_kContextSize); if (contextSize != null) ctx.arrangement.setSize(Slots.contextPanel, contextSize); + final dockVisible = s.get(_kDockVisible); + if (dockVisible != null) ctx.arrangement.setVisible(Slots.dock, dockVisible); + final dockSize = s.get(_kDockSize); + if (dockSize != null) ctx.arrangement.setSize(Slots.dock, dockSize); final editorRatio = s.get(_kEditorRatio); if (editorRatio != null) ctx.arrangement.setEditorRatio(editorRatio); final activeLeft = s.get(_kActiveLeft); @@ -161,6 +165,8 @@ class DefaultLayoutExtension extends ClideExtension { s.set(_kSidebarSize, a.sizeOf(Slots.sidebar)); s.set(_kContextSize, a.sizeOf(Slots.contextPanel)); s.set(_kEditorRatio, a.editorRatio); + s.set(_kDockVisible, a.isVisible(Slots.dock)); + s.set(_kDockSize, a.sizeOf(Slots.dock)); } void _persistActiveTabs(ClideExtensionContext ctx) { @@ -179,6 +185,8 @@ class DefaultLayoutExtension extends ClideExtension { static const _kEditorRatio = 'project.layout.editor.ratio'; static const _kActiveLeft = 'project.layout.sidebar.activeTab'; static const _kActiveRight = 'project.layout.context.activeTab'; + static const _kDockVisible = 'project.layout.dock.visible'; + static const _kDockSize = 'project.layout.dock.size'; Future _reset(List args) async { final preset = _preset; diff --git a/lib/builtin/output/output.dart b/lib/builtin/output/output.dart index bdf6d48e..c10e6e53 100644 --- a/lib/builtin/output/output.dart +++ b/lib/builtin/output/output.dart @@ -1,2 +1,4 @@ +export 'src/dock_status_item.dart'; +export 'src/extension.dart'; export 'src/output_controller.dart'; export 'src/output_view.dart'; diff --git a/lib/builtin/output/src/dock_status_item.dart b/lib/builtin/output/src/dock_status_item.dart new file mode 100644 index 00000000..242ea045 --- /dev/null +++ b/lib/builtin/output/src/dock_status_item.dart @@ -0,0 +1,88 @@ +/// Status-bar widget for the output dock (T-54 / D-87): merged health + +/// toggle. Replaces the old app-status item — green ✓ when the log is clean, +/// ⚠/✕ counts when not; click (or ⌘J) toggles the dock; chevron shows state. +library; + +import 'dart:async'; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class DockStatusItem extends StatefulWidget { + const DockStatusItem({super.key}); + + @override + State createState() => _DockStatusItemState(); +} + +class _DockStatusItemState extends State { + KernelServices? _kernel; + StreamSubscription? _ringSub; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final k = ClideKernel.of(context); + if (identical(k, _kernel)) return; + _kernel = k; + _ringSub?.cancel(); + _ringSub = k.logRing.changes.listen((_) { + if (mounted) setState(() {}); + }); + } + + void _toggle(KernelServices kernel) { + final a = kernel.arrangement; + final opening = !a.isVisible(Slots.dock); + a.setVisible(Slots.dock, opening); + if (opening && kernel.panels.activeTabIn(Slots.dock) == null) { + kernel.panels.activateTab(Slots.dock, 'output.panel'); + } + } + + @override + void dispose() { + _ringSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final kernel = ClideKernel.of(context); + return ListenableBuilder( + listenable: kernel.arrangement, + builder: (ctx, _) { + final tokens = ClideTheme.of(ctx).surface; + final open = kernel.arrangement.isVisible(Slots.dock); + final errors = kernel.logRing.countAtLeast(LogLevel.error); + final warns = kernel.logRing.countAtLeast(LogLevel.warn) - errors; + final (String badge, Color color) = errors > 0 + ? ('✕ $errors', tokens.statusError) + : warns > 0 + ? ('⚠ $warns', tokens.statusWarning) + : ('✓', tokens.statusSuccess); + return Semantics( + button: true, + label: 'toggle output dock', + child: GestureDetector( + onTap: () => _toggle(kernel), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClideText('${open ? '▼' : '▲'} Output ', fontSize: clideFontCaption, color: tokens.globalForeground), + ClideText(badge, fontSize: clideFontCaption, color: color), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/builtin/output/src/extension.dart b/lib/builtin/output/src/extension.dart new file mode 100644 index 00000000..70060a76 --- /dev/null +++ b/lib/builtin/output/src/extension.dart @@ -0,0 +1,63 @@ +/// The output-dock builtin (T-54 / D-87): contributes the Output tab + the +/// merged health/toggle status-bar widget, and the `dock.toggle` command. +library; + +import 'package:clide/builtin/output/src/dock_status_item.dart'; +import 'package:clide/builtin/output/src/output_view.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; + +class OutputExtension extends ClideExtension { + ClideExtensionContext? _ctx; + + @override + String get id => 'builtin.output'; + @override + String get title => 'Output'; + @override + String get version => '0.1.0'; + + /// The dock slot is registered by the default-layout preset; depend on it so + /// the slot exists before this tab contributes. + @override + List get dependsOn => const ['builtin.default-layout']; + + @override + Future activate(ClideExtensionContext ctx) async { + _ctx = ctx; + } + + @override + List get contributions => [ + TabContribution( + id: 'output.panel', + slot: Slots.dock, + title: 'Output', + priority: -100, // sort before Problems in the dock tab bar + build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing), + ), + StatusItemContribution( + id: 'output.dock-toggle', + priority: 100, // right group, replacing the old app-status item + build: (_) => const DockStatusItem(), + ), + CommandContribution( + id: 'dock.toggle', + command: 'dock.toggle', + title: 'Toggle output dock', + defaultBinding: 'ctrl+j', + run: (_) async { + final ctx = _ctx; + if (ctx == null) return IpcResponse.ok(id: '', data: const {}); + final a = ctx.arrangement; + final opening = !a.isVisible(Slots.dock); + a.setVisible(Slots.dock, opening); + if (opening && ctx.panels.activeTabIn(Slots.dock) == null) { + ctx.panels.activateTab(Slots.dock, 'output.panel'); + } + return IpcResponse.ok(id: '', data: {'dock': opening}); + }, + ), + ]; +} diff --git a/lib/builtin/problems/src/extension.dart b/lib/builtin/problems/src/extension.dart index b1897232..cc69f0ab 100644 --- a/lib/builtin/problems/src/extension.dart +++ b/lib/builtin/problems/src/extension.dart @@ -1,7 +1,6 @@ import 'package:clide/builtin/problems/src/problems_view.dart'; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; -import 'package:clide/widgets/widgets.dart'; class ProblemsExtension extends ClideExtension { @override @@ -15,11 +14,12 @@ class ProblemsExtension extends ClideExtension { @override List get contributions => [ + // Moved out of the sidebar into the bottom dock (D-87): no duplication, + // and the dock's width fits `severity · file:line · message` rows. TabContribution( id: 'problems.panel', - slot: Slots.sidebar, + slot: Slots.dock, title: 'Problems', - icon: PhosphorIcons.warningCircle, titleKey: 'tab.title', i18nNamespace: id, priority: -50, diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index a32f2fab..acffab97 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -18,6 +18,7 @@ import 'package:clide/kernel/src/i18n/catalog_loader.dart'; import 'package:clide/kernel/src/i18n/i18n.dart'; import 'package:clide/kernel/src/ipc/client.dart'; import 'package:clide/kernel/src/log.dart'; +import 'package:clide/kernel/src/log_ring.dart'; import 'package:clide/kernel/src/net.dart'; import 'package:clide/kernel/src/notify.dart'; import 'package:clide/kernel/src/os.dart'; @@ -74,6 +75,7 @@ class KernelServices { required this.keymap, required this.textZoom, required this.toast, + required this.logRing, }); final Logger log; @@ -109,6 +111,9 @@ class KernelServices { final TextZoom textZoom; final ToastService toast; + /// Bounded retention of [log] records, for the output dock (T-54 / D-87). + final LogRing logRing; + static Future boot({ required Directory appDir, required List bundledThemes, @@ -126,7 +131,8 @@ class KernelServices { Future Function(String path)? onValidateProject, DaemonBus? sharedBus, }) async { - final log = Logger(); + final logRing = LogRing(); + final log = Logger(sinks: [stderrSink, logRing.add]); final events = sharedBus ?? DaemonBus(); final messages = MessageBus(); @@ -226,6 +232,7 @@ class KernelServices { return KernelServices( log: log, + logRing: logRing, settings: settings, events: events, messages: messages, diff --git a/lib/kernel/src/panels/layout_preset.dart b/lib/kernel/src/panels/layout_preset.dart index b217c3e7..5ca650e3 100644 --- a/lib/kernel/src/panels/layout_preset.dart +++ b/lib/kernel/src/panels/layout_preset.dart @@ -32,6 +32,14 @@ LayoutPresetContribution classicPreset() => const LayoutPresetContribution( minSize: 220, maxSize: 1000, ), + LayoutSlot( + slot: Slots.dock, + position: SlotPosition.bottom, + defaultSize: 200, + minSize: 100, + maxSize: 600, + visible: false, + ), LayoutSlot( slot: Slots.statusbar, position: SlotPosition.bottom, diff --git a/lib/kernel/src/panels/slot_id.dart b/lib/kernel/src/panels/slot_id.dart index f77ac55c..44d94649 100644 --- a/lib/kernel/src/panels/slot_id.dart +++ b/lib/kernel/src/panels/slot_id.dart @@ -22,6 +22,10 @@ abstract class Slots { static const workspace = SlotId('workspace'); static const contextPanel = SlotId('context'); static const statusbar = SlotId('statusbar'); + + /// Bottom output dock — read-only logs + problems (T-54 / D-87). Toggled; + /// hidden by default. + static const dock = SlotId('dock'); static const toolbar = SlotId('toolbar.main'); static const commandPalette = SlotId('commandPalette'); static const tray = SlotId('tray'); diff --git a/lib/main.dart b/lib/main.dart index 594553eb..390efc5c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,7 +16,7 @@ import 'package:clide/builtin/git/git.dart'; import 'package:clide/builtin/search/search.dart'; import 'package:clide/builtin/grammars_core/grammars_core.dart'; import 'package:clide/builtin/graph/graph.dart'; -import 'package:clide/builtin/ipc_status/ipc_status.dart'; +import 'package:clide/builtin/output/output.dart'; import 'package:clide/builtin/keybindings_ui/keybindings_ui.dart'; import 'package:clide/builtin/markdown/markdown.dart'; import 'package:clide/builtin/pql/pql.dart'; @@ -340,6 +340,9 @@ Future main() async { // only reads it at request time (post-boot), so capturing it here is safe. kernelReaderNav = services.readerNav; kernelMessages = services.messages; + // 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); // Register every built-in. Tier 0 activates only the four that do // real work; the rest compile in as stubs so the extensions-ui can @@ -349,7 +352,7 @@ Future main() async { services.extensions ..register(DefaultLayoutExtension()) ..register(WelcomeExtension()) - ..register(IpcStatusExtension()) + ..register(OutputExtension()) ..register(ThemePickerExtension()) // Sidebar: tickets first, then decisions, files, git, pql, problems ..register(TicketsExtension()) diff --git a/test/builtin/output/dock_status_item_test.dart b/test/builtin/output/dock_status_item_test.dart new file mode 100644 index 00000000..ad6cb911 --- /dev/null +++ b/test/builtin/output/dock_status_item_test.dart @@ -0,0 +1,52 @@ +/// T-54: the merged health + dock-toggle status-bar widget (D-87). +library; + +import 'package:clide/builtin/default_layout/default_layout.dart'; +import 'package:clide/builtin/output/output.dart'; +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'; + +bool _textIs(Object? w, String s) => w is ClideText && w.data == s; + +void main() { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + f.services.extensions.register(DefaultLayoutExtension()); + await f.services.extensions.activate('builtin.default-layout'); + }); + tearDown(() => f.dispose()); + + testWidgets('reads clean (green ✓), then shows the error count', (tester) async { + await tester.pumpWidget(harness(f, const DockStatusItem())); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, '✓')), findsOneWidget); + + f.services.logRing.add(LogRecord(level: LogLevel.error, source: 'x', message: 'boom', timestamp: DateTime.utc(2026))); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, '✕ 1')), findsOneWidget); + }); + + testWidgets('shows a warn count when there are warnings but no errors', (tester) async { + f.services.logRing.add(LogRecord(level: LogLevel.warn, source: 'x', message: 'w', timestamp: DateTime.utc(2026))); + await tester.pumpWidget(harness(f, const DockStatusItem())); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, '⚠ 1')), findsOneWidget); + }); + + testWidgets('tapping toggles the dock open', (tester) async { + await tester.pumpWidget(harness(f, const DockStatusItem())); + await tester.pumpAndSettle(); + expect(f.services.arrangement.isVisible(Slots.dock), isFalse); + await tester.tap(find.byType(DockStatusItem)); + await tester.pumpAndSettle(); + expect(f.services.arrangement.isVisible(Slots.dock), isTrue); + expect(find.byWidgetPredicate((w) => _textIs(w, '▼ Output ')), findsOneWidget); + }); +} diff --git a/test/builtin/output/output_extension_test.dart b/test/builtin/output/output_extension_test.dart new file mode 100644 index 00000000..bcd36143 --- /dev/null +++ b/test/builtin/output/output_extension_test.dart @@ -0,0 +1,42 @@ +/// T-54: OutputExtension wires the dock tab, the status toggle, and the +/// dock.toggle command (D-87). +library; + +import 'package:clide/builtin/default_layout/default_layout.dart'; +import 'package:clide/builtin/output/output.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +void main() { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + f.services.extensions + ..register(DefaultLayoutExtension()) + ..register(OutputExtension()); + await f.services.extensions.activate('builtin.default-layout'); + await f.services.extensions.activate('builtin.output'); + }); + tearDown(() => f.dispose()); + + test('contributes the Output dock tab, a status item, and dock.toggle', () { + final ext = OutputExtension(); + final tabs = ext.contributions.whereType(); + expect(tabs.any((t) => t.id == 'output.panel' && t.slot == Slots.dock), isTrue); + expect(ext.contributions.whereType(), isNotEmpty); + expect(f.services.commands.get('dock.toggle'), isNotNull); + }); + + test('dock.toggle opens the dock + activates Output, then closes it', () async { + expect(f.services.arrangement.isVisible(Slots.dock), isFalse); + await f.services.commands.execute('dock.toggle'); + expect(f.services.arrangement.isVisible(Slots.dock), isTrue); + expect(f.services.panels.activeTabIn(Slots.dock), 'output.panel'); + await f.services.commands.execute('dock.toggle'); + expect(f.services.arrangement.isVisible(Slots.dock), isFalse); + }); +}