add ClidePane primitive + focus-driven status-bar slot

Replace the MessageBus-based pane-context slot with a focus-driven one.
Panes keep their status widget locally; the FocusTracker holds the
focused pane's widget (activeStatusWidget) and ClidePane conveys it to
the shared slot only while its contribution is focused, re-conveying on
change and clearing on blur. The status-bar item just renders
focus.activeStatusWidget, height-clamped and marquee-scrolled when it
overflows. Removes the publish/subscribe race the bus version had.

T-150.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 11:23:30 +02:00
co-authored by Claude Opus 4.7
parent 9590f555c9
commit 478cff6050
13 changed files with 492 additions and 116 deletions
+45 -33
View File
@@ -15,7 +15,6 @@ import 'conversation_controller.dart';
import 'conversation_view.dart';
import 'session_naming.dart';
import 'tmux_session.dart' as tmux;
import 'pane_context_status.dart';
import 'transcript_publisher.dart';
import 'transcript_reader.dart';
@@ -26,16 +25,22 @@ class ClaudePane extends StatefulWidget {
this.secondaryIndex,
this.showChrome = true,
this.active = true,
this.contributionId = 'claude.primary',
}) : assert(isPrimary || secondaryIndex != null, 'secondary panes need an index');
final bool isPrimary;
final bool showChrome;
final int? secondaryIndex;
/// Whether this pane is the visible/focused tab. Only the active pane
/// publishes its status to the status-bar context slot (T-145).
/// Whether this pane is the visible/focused sub-tab. Only the active
/// pane publishes its status to the status-bar context slot (T-145).
final bool active;
/// Workspace contribution id this pane lives under (the Claude tab —
/// keep in sync with the extension's TabContribution id). The status
/// slot shows our message only while this contribution is focused.
final String contributionId;
@override
State<ClaudePane> createState() => _ClaudePaneState();
}
@@ -63,22 +68,18 @@ class _ClaudePaneState extends State<ClaudePane> {
bool _spawned = false;
bool _usingTmux = false;
// Publish this pane's status line to the status-bar context slot, but
// only when it's the active tab — the active pane owns the slot; an
// inactive pane staying quiet lets the active one win without a race
// (T-145). Switching tabs re-publishes from the newly-active pane.
void _publishContext() {
if (!widget.active || _status.isEmpty) return;
final messages = _kernel()?.messages;
if (messages == null) return;
publishPaneContext(messages, 'builtin.claude', formatStatusLine(_status));
}
@override
void didUpdateWidget(ClaudePane old) {
super.didUpdateWidget(old);
// Became the active tab → push our status into the slot.
if (widget.active && !old.active) _publishContext();
// The status line surfaced to the bottom status bar via ClidePane —
// null until Claude reports a model/mode/context. ClidePane conveys it
// while this pane is the focused one (T-150).
Widget? _statusWidget(SurfaceTokens tokens) {
if (_status.isEmpty) return null;
return ClideText(
formatStatusLine(_status),
fontSize: clideFontSmall,
fontFamily: clideMonoFamily,
color: tokens.statusBarForeground,
maxLines: 1,
);
}
@override
@@ -279,10 +280,11 @@ class _ClaudePaneState extends State<ClaudePane> {
channel: channel,
);
_conversation = ConversationController.fromBus(messages: messages, channel: channel);
// On status change, rebuild — ClidePane re-conveys the new statusWidget
// to the bar while this pane is focused (T-150).
_statusSub = _feed!.statusStream.listen((s) {
if (!mounted) return;
setState(() => _status = s);
_publishContext();
});
_subscribe();
setState(() {});
@@ -345,6 +347,7 @@ class _ClaudePaneState extends State<ClaudePane> {
@override
Widget build(BuildContext context) {
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
final tokens = ClideTheme.of(context).surface;
final Widget body;
if (_error != null) {
@@ -376,20 +379,29 @@ class _ClaudePaneState extends State<ClaudePane> {
body = const Center(child: ClideText('attaching…', muted: true));
}
if (!widget.showChrome) return body;
final content = widget.showChrome
? ClidePaneChrome(
title: title,
subtitle: _error ?? _statusLine,
onClose: widget.isPrimary
? null
: () {
final id = _paneId;
if (id != null) {
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
}
},
child: body,
)
: body;
return ClidePaneChrome(
title: title,
subtitle: _error ?? _statusLine,
onClose: widget.isPrimary
? null
: () {
final id = _paneId;
if (id != null) {
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
}
},
child: body,
// Surface this pane's status to the bottom status-bar slot while it's
// the focused pane (T-150).
return ClidePane(
contributionId: widget.contributionId,
active: widget.active,
statusWidget: _statusWidget(tokens),
child: content,
);
}
}
+29 -59
View File
@@ -1,74 +1,44 @@
/// The status-bar "in-pane context" slot (T-145).
///
/// A generic, publisher-agnostic slot: a pane publishes a short status
/// string to [paneContextChannel] on the MessageBus, and the bottom
/// status bar shows the latest one. The active pane publishes (an
/// inactive pane stays quiet), so switching tabs swaps the slot to the
/// newly-active pane's message. The Claude pane is the first publisher
/// (model · permission-mode · context); other panes can use the same
/// channel.
/// Status-bar item that shows the *focused* pane's status widget
/// (T-150). The content comes from whichever pane is focused — each pane
/// surfaces its own [ClidePane.statusWidget] via [FocusTracker], so this
/// item is generic: it just renders `focus.activeStatusWidget`, clamped
/// to a fixed height and marquee-scrolled when it overflows. Nothing
/// (and no space) when no focused pane has a status — so it clears on
/// focus change.
library;
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// MessageBus channel for the status-bar context slot. Data: `{'text': String}`.
const paneContextChannel = 'statusbar.context';
/// Max width the slot occupies in the status bar before marquee kicks in.
const double _slotMaxWidth = 360;
/// Publish [text] to the context slot (empty string clears it).
void publishPaneContext(MessageBus messages, String publisher, String text) {
messages.publish(publisher, paneContextChannel, {'text': text});
}
/// Fixed slot height — panes can render anything, but not blow up the bar.
const double _slotHeight = 16;
/// Status-bar item that shows the latest pane-context message (nothing
/// when empty). Subscribes to the bus itself via the ambient kernel.
class PaneContextStatusItem extends StatefulWidget {
class PaneContextStatusItem extends StatelessWidget {
const PaneContextStatusItem({super.key});
@override
State<PaneContextStatusItem> createState() => _PaneContextStatusItemState();
}
class _PaneContextStatusItemState extends State<PaneContextStatusItem> {
StreamSubscription<Message>? _sub;
String _text = '';
bool _subscribed = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_subscribed) return;
_subscribed = true;
final messages = ClideKernel.of(context).messages;
_sub = messages.subscribe(channel: paneContextChannel).listen((m) {
final t = (m.data['text'] as String?) ?? '';
if (t == _text || !mounted) return;
setState(() => _text = t);
});
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_text.isEmpty) return const SizedBox.shrink();
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClideText(
_text,
fontSize: clideFontSmall,
fontFamily: clideMonoFamily,
color: tokens.statusBarForeground,
maxLines: 1,
),
final focus = ClideKernel.of(context).focus;
return ListenableBuilder(
listenable: focus,
builder: (ctx, _) {
final widget = focus.activeStatusWidget;
if (widget == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
height: _slotHeight,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _slotMaxWidth),
child: ClideMarquee(child: widget),
),
),
);
},
);
}
}
+22 -1
View File
@@ -11,6 +11,7 @@ import 'package:flutter/widgets.dart';
class FocusTracker extends ChangeNotifier {
SlotId? _slot;
String? _contributionId;
Widget? _activeStatusWidget;
final Map<SlotId, FocusScopeNode> _scopes = {};
/// Static order panels cycle through. Matches the visual left-to-right
@@ -26,17 +27,37 @@ class FocusTracker extends ChangeNotifier {
SlotId? get activeSlot => _slot;
String? get activeContributionId => _contributionId;
/// The focused pane's status-bar widget (T-150). The status bar renders
/// this; it's null when the focused contribution has no status, so the
/// bar clears automatically on focus change.
Widget? get activeStatusWidget => _activeStatusWidget;
void setActive({required SlotId slot, required String contributionId}) {
if (_slot == slot && _contributionId == contributionId) return;
_slot = slot;
_contributionId = contributionId;
// Focus moved — the previous pane's status no longer applies. The
// newly-focused pane re-conveys its own via [setStatusWidget].
_activeStatusWidget = null;
notifyListeners();
}
/// Convey [widget] (or null) as the status-bar content for
/// [contributionId]. Applied only while that contribution is focused —
/// a background pane's update is ignored, so it keeps its content
/// locally and re-conveys when it regains focus (T-150).
void setStatusWidget(String contributionId, Widget? widget) {
if (contributionId != _contributionId) return;
if (identical(_activeStatusWidget, widget)) return;
_activeStatusWidget = widget;
notifyListeners();
}
void clear() {
if (_slot == null && _contributionId == null) return;
if (_slot == null && _contributionId == null && _activeStatusWidget == null) return;
_slot = null;
_contributionId = null;
_activeStatusWidget = null;
notifyListeners();
}
+112
View File
@@ -0,0 +1,112 @@
/// Horizontal marquee (T-150). Shows [child] statically when it fits the
/// available width; when it's wider, scrolls it leftward in a seamless
/// loop (a second copy follows after [gap]). Clips to its box. Used by
/// the status-bar slot so a long pane status doesn't get truncated.
///
/// Own-the-stack: a `Ticker`-driven `SingleChildScrollView`, no package.
library;
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
class ClideMarquee extends StatefulWidget {
const ClideMarquee({
super.key,
required this.child,
this.pxPerSecond = 28,
this.gap = 48,
});
final Widget child;
/// Scroll speed when overflowing.
final double pxPerSecond;
/// Space between the looped copies.
final double gap;
@override
State<ClideMarquee> createState() => _ClideMarqueeState();
}
class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderStateMixin {
final GlobalKey _childKey = GlobalKey();
final ScrollController _scroll = ScrollController();
late final Ticker _ticker = createTicker(_tick);
double _contentWidth = 0;
double _viewportWidth = 0;
double _offset = 0;
Duration _last = Duration.zero;
bool get _overflow => _contentWidth > _viewportWidth + 0.5;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
}
@override
void didUpdateWidget(ClideMarquee old) {
super.didUpdateWidget(old);
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
}
void _measure() {
if (!mounted) return;
final w = _childKey.currentContext?.size?.width ?? 0;
if ((w - _contentWidth).abs() > 0.5) {
setState(() => _contentWidth = w);
}
if (_overflow && !_ticker.isActive) {
_last = Duration.zero;
_ticker.start();
} else if (!_overflow && _ticker.isActive) {
_ticker.stop();
_offset = 0;
if (_scroll.hasClients) _scroll.jumpTo(0);
}
}
void _tick(Duration elapsed) {
if (!_overflow || !_scroll.hasClients) return;
final dt = _last == Duration.zero ? 0.0 : (elapsed - _last).inMicroseconds / 1e6;
_last = elapsed;
final span = _contentWidth + widget.gap;
if (span <= 0) return;
_offset = (_offset + widget.pxPerSecond * dt) % span;
final max = _scroll.position.maxScrollExtent;
_scroll.jumpTo(_offset.clamp(0, max));
}
@override
void dispose() {
_ticker.dispose();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ClipRect(
child: LayoutBuilder(
builder: (ctx, constraints) {
_viewportWidth = constraints.maxWidth;
return SingleChildScrollView(
controller: _scroll,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
KeyedSubtree(key: _childKey, child: widget.child),
if (_overflow) ...[SizedBox(width: widget.gap), widget.child],
],
),
);
},
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
/// Behaviour-only wrapper every pane uses (T-150). No chrome — that's
/// [ClidePaneChrome]'s job. ClidePane handles cross-pane uniformity:
/// surfacing the pane's [statusWidget] to the bottom status bar while the
/// pane is focused, via [FocusTracker.setStatusWidget].
///
/// The widget lives with the pane; ClidePane only conveys it to the
/// shared slot while this pane is the shown one (its contribution is
/// focused and, for multi-pane contributions, it's the [active] sub-tab),
/// and re-conveys whenever [statusWidget] changes. A backgrounded pane
/// keeps its content locally and re-conveys on regaining focus.
library;
import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/focus.dart';
import 'package:flutter/widgets.dart';
class ClidePane extends StatefulWidget {
const ClidePane({
super.key,
required this.contributionId,
required this.child,
this.statusWidget,
this.active = true,
});
/// The workspace contribution id this pane belongs to (what
/// [FocusTracker.activeContributionId] reports when it's focused).
final String contributionId;
/// Status-bar content to surface while focused, or null for none.
final Widget? statusWidget;
/// For contributions hosting multiple panes (e.g. Claude's sub-tabs),
/// whether this is the visible one. Only the active pane conveys.
final bool active;
final Widget child;
@override
State<ClidePane> createState() => _ClidePaneState();
}
class _ClidePaneState extends State<ClidePane> {
FocusTracker? _focus;
bool _wired = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_wired) {
_wired = true;
_focus = ClideKernel.of(context).focus..addListener(_sync);
}
_sync();
}
@override
void didUpdateWidget(ClidePane old) {
super.didUpdateWidget(old);
if (old.statusWidget != widget.statusWidget || old.active != widget.active || old.contributionId != widget.contributionId) {
_sync();
}
}
bool get _shown => widget.active && _focus?.activeContributionId == widget.contributionId;
// Convey our status while shown. Re-entrancy is bounded: setStatusWidget
// notifies (→ _sync again), but the identical-widget guard there makes
// the second pass a no-op.
void _sync() {
if (_shown) _focus?.setStatusWidget(widget.contributionId, widget.statusWidget);
}
@override
void dispose() {
if (_shown) _focus?.setStatusWidget(widget.contributionId, null);
_focus?.removeListener(_sync);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
+2
View File
@@ -12,10 +12,12 @@ export 'src/clide_code_block.dart';
export 'src/clide_divider.dart';
export 'src/clide_filter_box.dart';
export 'src/clide_markdown.dart';
export 'src/clide_marquee.dart';
export 'src/clide_svg_view.dart';
export 'src/clide_icon.dart';
export 'src/clide_icon_rail.dart';
export 'src/clide_palette.dart';
export 'src/clide_pane.dart';
export 'src/clide_pane_chrome.dart';
export 'src/clide_resize_border.dart';
export 'src/clide_pty_view.dart';