diff --git a/CHANGELOG.md b/CHANGELOG.md index aee497c2..1147633b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. thread / handle / child-host / RSS counts to `clide-watchdog.log` — a climbing child or thread count is the leak signature. Survives a frozen main isolate; spawn failure is non-fatal. (T-435) +- **Live log-verbosity toggle.** The output dock's Level chip now sets the + running logger's level and persists `app.log.level` (not just a view filter), + and `clide log level []` does the same from the CLI — D-6 parity. The + choice survives restart. (T-433) ## [2.5.0] — 2026-06-14 diff --git a/lib/builtin/output/src/extension.dart b/lib/builtin/output/src/extension.dart index 0974235a..d3e3d927 100644 --- a/lib/builtin/output/src/extension.dart +++ b/lib/builtin/output/src/extension.dart @@ -2,6 +2,8 @@ /// merged health/toggle status-bar widget, and the `dock.toggle` command. library; +import 'dart:async'; + import 'package:clide/builtin/output/src/dock_status_item.dart'; import 'package:clide/builtin/output/src/output_view.dart'; import 'package:clide/clide.dart'; @@ -35,7 +37,20 @@ class OutputExtension extends ClideExtension { slot: Slots.dock, title: 'Output', priority: -100, // sort before Problems in the dock tab bar - build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing), + build: (ctx) { + // The Level chip is the live verbosity toggle (T-433): drive the kernel + // logger + persist app.log.level so the choice survives restart and + // matches the `clide log level` CLI (D-6 parity). + final k = ClideKernel.of(ctx); + return OutputView( + ring: k.logRing, + initialLevel: k.log.minLevel, + onMinLevelChanged: (level) { + k.log.minLevel = level; + unawaited(k.settings.set('app.log.level', level.name)); + }, + ); + }, ), StatusItemContribution( id: 'output.dock-toggle', diff --git a/lib/builtin/output/src/output_controller.dart b/lib/builtin/output/src/output_controller.dart index a14d15ba..0c7587b7 100644 --- a/lib/builtin/output/src/output_controller.dart +++ b/lib/builtin/output/src/output_controller.dart @@ -10,15 +10,23 @@ import 'package:clide/kernel/src/log_ring.dart'; import 'package:flutter/foundation.dart'; class OutputController extends ChangeNotifier { - OutputController(this.ring) { + OutputController(this.ring, {LogLevel? initialLevel, this.onMinLevelChanged}) : minLevel = initialLevel ?? LogLevel.debug { _sub = ring.changes.listen((_) => notifyListeners()); } final LogRing ring; late final StreamSubscription _sub; - /// Minimum level shown. Defaults to debug (trace is firehose-noise). - LogLevel minLevel = LogLevel.debug; + /// Invoked when the Level chip changes the level — the dock chip is the live + /// dev/prod verbosity toggle (T-433), not just a view filter. The owner wires + /// this to set the kernel `Logger.minLevel` and persist `app.log.level`. Null + /// in tests / when no kernel is attached, leaving the chip a pure view filter. + final void Function(LogLevel)? onMinLevelChanged; + + /// Minimum level shown — initialized from the kernel logger's level so the + /// chip reflects the real verbosity (which a `clide log level` CLI may have + /// already set), defaulting to debug (trace is firehose-noise). + LogLevel minLevel; /// Source filter; null = all sources. String? source; @@ -29,6 +37,7 @@ class OutputController extends ChangeNotifier { void setMinLevel(LogLevel level) { if (minLevel == level) return; minLevel = level; + onMinLevelChanged?.call(level); notifyListeners(); } diff --git a/lib/builtin/output/src/output_view.dart b/lib/builtin/output/src/output_view.dart index d0010510..ebac4932 100644 --- a/lib/builtin/output/src/output_view.dart +++ b/lib/builtin/output/src/output_view.dart @@ -10,18 +10,23 @@ import 'package:flutter/widgets.dart'; import 'output_controller.dart'; class OutputView extends StatefulWidget { - const OutputView({super.key, required this.ring}); + const OutputView({super.key, required this.ring, this.initialLevel, this.onMinLevelChanged}); /// The retained log buffer to render. The view owns a controller over it /// but never the ring itself (the app owns that). final LogRing ring; + /// Initial verbosity (the kernel logger's current level) + the sink that + /// applies a chip change to the kernel + persists it (T-433). + final LogLevel? initialLevel; + final void Function(LogLevel)? onMinLevelChanged; + @override State createState() => _OutputViewState(); } class _OutputViewState extends State { - late final OutputController _c = OutputController(widget.ring); + late final OutputController _c = OutputController(widget.ring, initialLevel: widget.initialLevel, onMinLevelChanged: widget.onMinLevelChanged); final ScrollController _scroll = ScrollController(); /// Follow the tail until the user scrolls up; resumes when they return to diff --git a/lib/main.dart b/lib/main.dart index 8ca4cea1..bb6c684a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,6 +40,7 @@ import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/git_commands.dart'; import 'package:clide/src/daemon/image_commands.dart'; +import 'package:clide/src/daemon/log_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/daemon/status_command.dart'; import 'package:clide/src/daemon/ui_command.dart'; @@ -144,6 +145,9 @@ Future main() async { // The kernel Logger, captured in the factory so a post-boot project switch // can rebuild the dispatcher with PTY breadcrumbs wired (T-434). Logger? kernelLog; + // The kernel settings store, captured post-boot so `clide log level` can + // persist app.log.level (T-433). + SettingsStore? kernelSettings; // 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 @@ -274,6 +278,12 @@ Future main() async { // 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)); + // `clide log level []` — the live verbosity toggle's CLI half (T-433, + // D-6 parity with the output-dock Level chip). Persists via the kernel + // settings, captured post-boot. + if (log != null) { + registerLogCommands(dispatcher, log, (name) async => await kernelSettings?.set('app.log.level', name)); + } // 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). @@ -423,6 +433,7 @@ Future main() async { kernelReaderNav = services.readerNav; kernelMessages = services.messages; kernelFilterStates = services.filterStates; + kernelSettings = services.settings; // 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/log_commands.dart b/lib/src/daemon/log_commands.dart new file mode 100644 index 00000000..af4cc0c4 --- /dev/null +++ b/lib/src/daemon/log_commands.dart @@ -0,0 +1,37 @@ +/// Registers `log.level` — the live dev/prod verbosity toggle (T-433). +/// +/// With no arg it reports the running [Logger]'s minimum level; with +/// `level=` it sets the Logger AND persists `app.log.level` so the +/// choice survives a restart (resolved at boot by `resolveLogLevel`). The UI +/// half is the output dock's Level chip — D-6 parity. Flutter-free + trivially +/// testable: the handler takes the Logger and a persist callback. +library; + +import 'package:clide/kernel/src/log.dart'; + +import '../ipc/envelope.dart'; +import 'dispatcher.dart'; + +/// Persists the chosen level name (the owner wires this to +/// `settings.set('app.log.level', name)`). +typedef LogLevelPersist = Future Function(String levelName); + +void registerLogCommands(DaemonDispatcher d, Logger log, LogLevelPersist persist) { + d.register('log.level', (req) async { + final raw = req.args['level']; + if (raw == null) { + // Read: report the current level + the vocabulary. + return IpcResponse.ok(id: req.id, data: {'level': log.minLevel.name, 'levels': LogLevel.values.map((l) => l.name).toList()}); + } + final level = parseLogLevel(raw is String ? raw : raw.toString()); + if (level == null) { + return IpcResponse.err( + id: req.id, + error: IpcError(code: 64, kind: 'bad_arg', message: 'unknown log level: $raw', hint: 'one of: ${LogLevel.values.map((l) => l.name).join(', ')}'), + ); + } + log.minLevel = level; + await persist(level.name); + return IpcResponse.ok(id: req.id, data: {'level': level.name}); + }); +} diff --git a/test/builtin/output/output_controller_test.dart b/test/builtin/output/output_controller_test.dart new file mode 100644 index 00000000..cc65a38b --- /dev/null +++ b/test/builtin/output/output_controller_test.dart @@ -0,0 +1,50 @@ +import 'package:clide/builtin/output/src/output_controller.dart'; +import 'package:clide/kernel/src/log.dart'; +import 'package:clide/kernel/src/log_ring.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('OutputController Level chip (T-433)', () { + test('initialLevel sets the starting level (reflects the kernel logger)', () { + final c = OutputController(LogRing(), initialLevel: LogLevel.warn); + expect(c.minLevel, LogLevel.warn); + c.dispose(); + }); + + test('defaults to debug when no initial level is given', () { + final c = OutputController(LogRing()); + expect(c.minLevel, LogLevel.debug); + c.dispose(); + }); + + test('setMinLevel updates the level, fires onMinLevelChanged + notifies', () { + final changes = []; + var notified = 0; + final c = OutputController(LogRing(), initialLevel: LogLevel.info, onMinLevelChanged: changes.add)..addListener(() => notified++); + + c.setMinLevel(LogLevel.warn); + expect(c.minLevel, LogLevel.warn); + expect(changes, [LogLevel.warn]); // the chip drove the kernel + persist hook + expect(notified, 1); + c.dispose(); + }); + + test('setting the same level is a no-op (no kernel write, no notify)', () { + final changes = []; + var notified = 0; + final c = OutputController(LogRing(), initialLevel: LogLevel.info, onMinLevelChanged: changes.add)..addListener(() => notified++); + + c.setMinLevel(LogLevel.info); + expect(changes, isEmpty); + expect(notified, 0); + c.dispose(); + }); + + test('with no callback the chip is a pure view filter (no throw)', () { + final c = OutputController(LogRing(), initialLevel: LogLevel.info); + expect(() => c.setMinLevel(LogLevel.error), returnsNormally); + expect(c.minLevel, LogLevel.error); + c.dispose(); + }); + }); +} diff --git a/test/daemon/log_commands_test.dart b/test/daemon/log_commands_test.dart new file mode 100644 index 00000000..68bb8ec8 --- /dev/null +++ b/test/daemon/log_commands_test.dart @@ -0,0 +1,55 @@ +import 'package:clide/kernel/src/log.dart'; +import 'package:clide/src/daemon/dispatcher.dart'; +import 'package:clide/src/daemon/log_commands.dart'; +import 'package:clide/src/ipc/envelope.dart'; +import 'package:test/test.dart'; + +void main() { + group('log.level command (T-433)', () { + test('no arg reports the current level + the vocabulary', () async { + final log = Logger(minLevel: LogLevel.info); + final d = DaemonDispatcher(); + registerLogCommands(d, log, (_) async {}); + + final r = await d.dispatch(IpcRequest(id: '1', cmd: 'log.level', args: const {})); + expect(r.ok, isTrue); + expect(r.data['level'], 'info'); + expect(r.data['levels'], containsAll(['trace', 'debug', 'info', 'warn', 'error'])); + }); + + test('a valid level sets the running logger AND persists it', () async { + final log = Logger(minLevel: LogLevel.info); + String? persisted; + final d = DaemonDispatcher(); + registerLogCommands(d, log, (name) async => persisted = name); + + final r = await d.dispatch(IpcRequest(id: '1', cmd: 'log.level', args: {'level': 'warn'})); + expect(r.ok, isTrue); + expect(r.data['level'], 'warn'); + expect(log.minLevel, LogLevel.warn); // live + expect(persisted, 'warn'); // durable + }); + + test('level name is case-insensitive', () async { + final log = Logger(minLevel: LogLevel.info); + final d = DaemonDispatcher(); + registerLogCommands(d, log, (_) async {}); + await d.dispatch(IpcRequest(id: '1', cmd: 'log.level', args: {'level': 'ERROR'})); + expect(log.minLevel, LogLevel.error); + }); + + test('an unknown level errors (code 64), leaves the logger untouched, does not persist', () async { + final log = Logger(minLevel: LogLevel.info); + var persistCalls = 0; + final d = DaemonDispatcher(); + registerLogCommands(d, log, (_) async => persistCalls++); + + final r = await d.dispatch(IpcRequest(id: '1', cmd: 'log.level', args: {'level': 'loud'})); + expect(r.ok, isFalse); + expect(r.error?.code, 64); + expect(r.error?.hint, contains('warn')); + expect(log.minLevel, LogLevel.info); + expect(persistCalls, 0); + }); + }); +}