From 959ec7ddc42cceb4610da1208184dded80431f25 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 13:25:49 +0200 Subject: [PATCH] =?UTF-8?q?Config=20tab=20=E2=86=92=20live=20control=20pan?= =?UTF-8?q?el;=20sidebar=20styling=20pass=20(T-414)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude sidebar's settings table was read-only 12px rows. It becomes the power panel's core: - model / effort / permission-mode rows are popover controls on the owned anchored-menu primitive (ClideAnchoredOverlay + ClideMenu), showing the LIVE session values (SessionStatus, falling back to the probe/settings) with the active option marked. - Picking an option publishes the explicit slash command (`/effort xhigh`) on builtin.claude/command; the PRIMARY pane subscribes and executes it through the same _send routing the composer uses — the control and the typed command are one code path (D-6), which is also what lets the sidebar drive /effort's respawn flow without reaching into the pane. Only the primary pane listens (controls target the primary session; a second listener would double-execute). - Styling pass (user request): shared meta tables move from 12px- everything to 13px labels/values, accent-coloured section headers, wider row pitch; control rows get hover affordance + caret. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + .../claude/src/claude_meta_sidebar.dart | 2 + lib/builtin/claude/src/claude_pane.dart | 14 ++ .../claude/src/meta_sidebar/config_tab.dart | 167 ++++++++++++++++-- .../claude/src/meta_sidebar/models.dart | 16 +- .../claude/claude_meta_sidebar_test.dart | 27 +++ 6 files changed, 209 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ac875e..480de7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **The Claude sidebar Config tab is a live control panel.** Model, effort, and + permission mode are popover controls showing the running session's values; + picking an option drives the session through the same path as the typed slash + command. The sidebar tables also got a visual pass — larger type, accent + section headers, more breathing room. (T-414) - **The TUI command family opens clide surfaces.** `/permissions` sets the mode directly or opens a picker; `/status`, `/config`, `/mcp`, `/agents`, `/hooks` jump to the matching Claude sidebar tab; `/memory` opens CLAUDE.md in the diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index 00d1a5c5..89ab3f8b 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -312,6 +312,8 @@ class _ClaudeMetaSidebarState extends State { ), SidebarTab.config => ConfigTabView( config: _config, + status: _primaryStatus, + models: _orchestrator?.byId('primary')?.session.availableModels, expanded: _expanded, onToggleSection: (section) => setState(() { if (_expanded.contains(section)) { diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index cd888481..97cc3b25 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -74,6 +74,7 @@ class _ClaudePaneState extends State { StreamSubscription? _statusSub; StreamSubscription? _endSub; StreamSubscription? _projectSub; + StreamSubscription? _commandSub; StreamSubscription? _modelErrorSub; ConversationController? _conversation; StreamJsonSession? _session; @@ -177,6 +178,18 @@ class _ClaudePaneState extends State { // GlobalKey and spawns once, so without this it would keep the previous // repo's session after a switch (T-269). _projectSub = ClideKernel.of(context).events.on().listen(_onProjectChanged); + // Sidebar controls (and any future surface) drive this pane's session by + // publishing slash-command text on builtin.claude/command (T-414) — + // executed through the exact _send routing the composer uses, so the + // control and the typed command are one code path (D-6). Only the + // primary pane listens: the controls target the primary session, and a + // second listener would double-execute. + if (widget.isPrimary) { + _commandSub = ClideKernel.of(context).messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen((msg) { + final text = msg.data['text'] as String?; + if (text != null && text.isNotEmpty) _send(text); + }); + } // Re-fold the conversation when the activity fold-level setting changes // (claude.activity.fold-level command, T-235). ClideKernel.of(context).settings.addListener(_onSettingsChanged); @@ -192,6 +205,7 @@ class _ClaudePaneState extends State { activeClaudeConfig?.removeListener(_onConfigChanged); _kernel?.settings.removeListener(_onSettingsChanged); _projectSub?.cancel(); + _commandSub?.cancel(); _projectSub = null; _statusSub?.cancel(); _statusSub = null; diff --git a/lib/builtin/claude/src/meta_sidebar/config_tab.dart b/lib/builtin/claude/src/meta_sidebar/config_tab.dart index be29ed30..2915e729 100644 --- a/lib/builtin/claude/src/meta_sidebar/config_tab.dart +++ b/lib/builtin/claude/src/meta_sidebar/config_tab.dart @@ -1,22 +1,40 @@ -/// The Config tab (T-183): the pinned settings table over [ClaudeConfig] -/// plus the skills/agents/commands/hooks/permissions/MCP accordion. -/// Split out of claude_meta_sidebar.dart (T-395). The accordion's -/// expansion state lives in the parent (it survives tab switches) and -/// arrives as a prop + toggle callback. +/// The Config tab (T-183): the settings table over [ClaudeConfig] plus the +/// skills/agents/commands/hooks/permissions/MCP accordion. Split out of +/// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in +/// the parent (it survives tab switches) and arrives as a prop + toggle +/// callback. +/// +/// T-414 makes the settings table a control panel: model / effort / +/// permission-mode rows are live popover controls. Picking an option +/// publishes the explicit slash command (`/model sonnet`) on the +/// `builtin.claude`/`command` channel; the primary Claude pane executes it +/// through the same `_send` routing the composer uses — one implementation, +/// two surfaces (D-6). library; import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel; import 'package:clide/builtin/claude/src/meta_sidebar/models.dart'; +import 'package:clide/builtin/claude/src/stream_json_session.dart' show ModelOption, kEffortLevels, kFallbackModels, kPermissionModes; +import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; class ConfigTabView extends StatelessWidget { - const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection}); + const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection, this.status, this.models}); final ClaudeConfig? config; + /// The primary session's live status — drives the control rows' current + /// values. Null before the session reports (controls fall back to the + /// probe/settings values). + final SessionStatus? status; + + /// Models selectable for the primary session (from its `initialize` + /// response); falls back to [kFallbackModels]. + final List? models; + /// Sections currently expanded — owned by the parent state. final Set expanded; final void Function(ConfigSection section) onToggleSection; @@ -29,19 +47,34 @@ class ConfigTabView extends StatelessWidget { return metaPlaceholder('Claude environment not loaded.'); } final settings = cfg.settings; - final model = cfg.probe?.model ?? settings['model']?.toString() ?? '—'; + final model = status?.model ?? cfg.probe?.model ?? settings['model']?.toString() ?? 'default'; final outputStyle = settings['outputStyle']?.toString() ?? 'default'; - final mode = cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default'; + final mode = status?.permissionMode ?? cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default'; + final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default'; final children = [ - // Pinned SETTINGS table — not collapsible. + // Pinned SETTINGS control panel — not collapsible. Padding( - padding: const EdgeInsets.only(bottom: 6), - child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted), + padding: const EdgeInsets.only(bottom: 8), + child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader), + ), + SettingControlRow( + label: 'model', + value: model, + valueColor: tokens.globalFocus, + options: (models == null || models!.isEmpty) ? kFallbackModels : models!, + isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()), + command: 'model', + ), + SettingControlRow(label: 'effort', value: effort, options: kEffortLevels, isActive: (o) => o.value == effort, command: 'effort'), + SettingControlRow( + label: 'permission mode', + value: permissionModeLabel(mode), + options: kPermissionModes, + isActive: (o) => o.value == mode, + command: 'permissions', ), - _configRow(tokens, 'model', model, valueColor: tokens.globalFocus), _configRow(tokens, 'output style', outputStyle), - _configRow(tokens, 'permission mode', permissionModeLabel(mode)), _configRow(tokens, 'source', '~/.claude + .claude'), // ---- Accordion sections ---- @@ -57,7 +90,7 @@ class ConfigTabView extends StatelessWidget { return ListView(padding: const EdgeInsets.all(12), children: children); } - /// One key→value row in the pinned SETTINGS table. + /// One read-only key→value row in the pinned SETTINGS table. Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) { return Padding( padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), @@ -66,10 +99,10 @@ class ConfigTabView extends StatelessWidget { children: [ SizedBox( width: kMetaLabelColumnWidth, - child: ClideText(label, muted: true, fontSize: clideFontSmall), + child: ClideText(label, muted: true, fontSize: kMetaFont), ), Expanded( - child: ClideText(value, fontSize: clideFontSmall, color: valueColor ?? tokens.globalForeground), + child: ClideText(value, fontSize: kMetaFont, color: valueColor ?? tokens.globalForeground), ), ], ), @@ -223,3 +256,105 @@ class ConfigTabView extends StatelessWidget { return rows; } } + +/// One live setting row (T-414): label + current value as a popover control on +/// the owned anchored-menu primitive. Picking an option publishes the explicit +/// slash command on `builtin.claude`/`command`; the primary Claude pane +/// executes it through its normal `_send` routing — so the sidebar control and +/// the typed command are literally the same code path (D-6). +class SettingControlRow extends StatefulWidget { + const SettingControlRow({ + super.key, + required this.label, + required this.value, + required this.options, + required this.isActive, + required this.command, + this.valueColor, + }); + + final String label; + + /// Current value, displayed on the trigger. + final String value; + final Color? valueColor; + + final List options; + final bool Function(ModelOption option) isActive; + + /// The slash-command token this control drives (`model`, `effort`, + /// `permissions`); a pick publishes `/ `. + final String command; + + @override + State createState() => _SettingControlRowState(); +} + +class _SettingControlRowState extends State { + final ClideOverlayController _overlay = ClideOverlayController(); + + void _pick(String value) { + ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': '/${widget.command} $value'}); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Padding( + padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: kMetaLabelColumnWidth, + child: ClideText(widget.label, muted: true, fontSize: kMetaFont), + ), + Expanded( + child: ClideAnchoredOverlay( + controller: _overlay, + align: ClideAnchorAlign.start, + overlayBuilder: (ctx, c) => ClideMenu( + onClose: c.close, + entries: [ + for (final o in widget.options) + ClideMenuItem( + label: o.description.isEmpty ? o.displayName : '${o.displayName} — ${o.description}', + active: widget.isActive(o), + semanticLabel: '${widget.label}: ${o.displayName}', + onSelect: () => _pick(o.value), + ), + ], + ), + anchor: Semantics( + button: true, + label: '${widget.label}: ${widget.value}. Click to change.', + excludeSemantics: true, + onTap: _overlay.toggle, + child: ClideTappable( + tooltip: 'change ${widget.label}', + onTap: _overlay.toggle, + builder: (ctx, hovered, _) => DecoratedBox( + decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ClideText(widget.value, fontSize: kMetaFont, color: widget.valueColor ?? tokens.globalForeground, maxLines: 1), + ), + const SizedBox(width: 4), + ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/builtin/claude/src/meta_sidebar/models.dart b/lib/builtin/claude/src/meta_sidebar/models.dart index 764d1fc9..59345c9c 100644 --- a/lib/builtin/claude/src/meta_sidebar/models.dart +++ b/lib/builtin/claude/src/meta_sidebar/models.dart @@ -9,7 +9,11 @@ import 'package:flutter/widgets.dart'; /// The shared label-column width + row pitch the Activity and Config tables /// both use, so toggling between tabs keeps every value at the same x and y. const double kMetaLabelColumnWidth = 110; -const double kMetaRowPitch = 4; +const double kMetaRowPitch = 6; + +/// Type scale for the sidebar tables (T-414 styling pass): labels/values read +/// at meta size (13) — the old 12px-everything read as bland and cramped. +const double kMetaFont = clideFontMeta; /// The sidebar's sub-tabs. enum SidebarTab { activity, team, config } @@ -36,7 +40,7 @@ class MetaRow { /// The muted empty-state body shared by every tab. Widget metaPlaceholder(String text) => Padding( padding: const EdgeInsets.all(12), - child: ClideText(text, muted: true, fontSize: clideFontSmall), + child: ClideText(text, muted: true, fontSize: kMetaFont), ); /// Key→value sections on the shared table geometry (Activity + Config). @@ -46,8 +50,8 @@ Widget buildMetaTable(SurfaceTokens tokens, List sections) { final s = sections[i]; children.add( Padding( - padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6), - child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted), + padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8), + child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader), ), ); for (final r in s.rows) { @@ -59,10 +63,10 @@ Widget buildMetaTable(SurfaceTokens tokens, List sections) { children: [ SizedBox( width: kMetaLabelColumnWidth, - child: ClideText(r.label, muted: true, fontSize: clideFontSmall), + child: ClideText(r.label, muted: true, fontSize: kMetaFont), ), Expanded( - child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground), + child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground), ), ], ), diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index b48e9d18..90ff5f21 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -133,6 +133,33 @@ void main() { expect(find.text('Claude environment not loaded.'), findsOneWidget); }); + testWidgets('a settings control publishes its slash command on pick (T-414)', (tester) async { + final dir = Directory.systemTemp.createTempSync('cfg'); + addTearDown(() => dir.deleteSync(recursive: true)); + final config = ClaudeConfig(globalDir: dir, cacheDir: dir); + final published = []; + final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen(published.add); + addTearDown(sub.cancel); + + await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config))); + await tester.pumpAndSettle(); + + // The three live controls render alongside the read-only rows. + expect(find.text('model'), findsOneWidget); + expect(find.text('effort'), findsOneWidget); + expect(find.text('permission mode'), findsOneWidget); + + // Open the effort control and pick a level → the explicit slash command + // goes out on the bus (the primary pane executes it via _send, D-6). + await tester.tap(find.bySemanticsLabel(RegExp('effort: .*Click to change.'))); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('xhigh')); + await tester.pumpAndSettle(); + + expect(published, hasLength(1)); + expect(published.single.data['text'], '/effort xhigh'); + }); + testWidgets('a meta.tab message switches the sub-tab (T-413 slash navigation)', (tester) async { await tester.pumpWidget(harness(f, sidebar(stats: stats))); await tester.pumpAndSettle();