feat(log): live verbosity toggle — dock Level chip + clide log level (T-433)

The boot-time toggle (CLIDE_LOG / app.log.level) existed; this makes it
adjustable at runtime, from the two surfaces that fit — D-6 parity — and NOT a
Claude-composer slash command (log verbosity isn't a Claude-session concept).

- Output dock Level chip: was a view-only filter; now also drives the kernel
  Logger.minLevel and persists app.log.level, so the choice is real (changes
  what's captured) and survives restart. Initialized from the logger's current
  level so it reflects a CLI change. A null callback keeps it a pure view
  filter (tests / no kernel).
- `clide log level [<level>]` (lib/src/daemon/log_commands.dart): no arg reports
  the level + vocabulary; a valid level sets the live logger and persists;
  unknown → bad_arg (code 64), logger untouched. Wired in buildDispatcher with
  the kernel logger + settings (captured post-boot).

Tested: the command (get/set/case-insensitive/bad-arg, live + persist) and the
controller (initial level, callback fires, same-level no-op, filter-only when
unwired). Coverage gate 95.05%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:32:56 +02:00
co-authored by Claude Opus 4.8
parent f8477816c4
commit 432a5f2d5a
8 changed files with 192 additions and 6 deletions
+4
View File
@@ -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 [<level>]` does the same from the CLI — D-6 parity. The
choice survives restart. (T-433)
## [2.5.0] — 2026-06-14
+16 -1
View File
@@ -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',
+12 -3
View File
@@ -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<void> _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();
}
+7 -2
View File
@@ -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<OutputView> createState() => _OutputViewState();
}
class _OutputViewState extends State<OutputView> {
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
+11
View File
@@ -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<void> 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<void> 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 [<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<String>('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<void> 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);
+37
View File
@@ -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=<name>` 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<void> 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});
});
}
@@ -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 = <LogLevel>[];
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 = <LogLevel>[];
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();
});
});
}
+55
View File
@@ -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(<String>['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);
});
});
}