Config tab → live control panel; sidebar styling pass (T-414)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:25:49 +02:00
co-authored by Claude Fable 5
parent 7c4ac182f6
commit 959ec7ddc4
6 changed files with 209 additions and 22 deletions
+5
View File
@@ -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
@@ -312,6 +312,8 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
),
SidebarTab.config => ConfigTabView(
config: _config,
status: _primaryStatus,
models: _orchestrator?.byId('primary')?.session.availableModels,
expanded: _expanded,
onToggleSection: (section) => setState(() {
if (_expanded.contains(section)) {
+14
View File
@@ -74,6 +74,7 @@ class _ClaudePaneState extends State<ClaudePane> {
StreamSubscription<SessionStatus>? _statusSub;
StreamSubscription<SessionEnd>? _endSub;
StreamSubscription<ProjectOpened>? _projectSub;
StreamSubscription<Message>? _commandSub;
StreamSubscription<String>? _modelErrorSub;
ConversationController? _conversation;
StreamJsonSession? _session;
@@ -177,6 +178,18 @@ class _ClaudePaneState extends State<ClaudePane> {
// 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<ProjectOpened>().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<ClaudePane> {
activeClaudeConfig?.removeListener(_onConfigChanged);
_kernel?.settings.removeListener(_onSettingsChanged);
_projectSub?.cancel();
_commandSub?.cancel();
_projectSub = null;
_statusSub?.cancel();
_statusSub = null;
@@ -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<ModelOption>? models;
/// Sections currently expanded — owned by the parent state.
final Set<ConfigSection> 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 = <Widget>[
// 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<ModelOption> options;
final bool Function(ModelOption option) isActive;
/// The slash-command token this control drives (`model`, `effort`,
/// `permissions`); a pick publishes `/<command> <option.value>`.
final String command;
@override
State<SettingControlRow> createState() => _SettingControlRowState();
}
class _SettingControlRowState extends State<SettingControlRow> {
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),
],
),
),
),
),
),
),
),
],
),
);
}
}
@@ -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<MetaSection> 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<MetaSection> 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),
),
],
),
@@ -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 = <Message>[];
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();