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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user