From 478cff6050e8d78d3ee769d0da1fe31677ee084f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 11:23:30 +0200 Subject: [PATCH] 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) --- .pql/changelog/ticket_history/2026-05.sql | 2 + .pql/changelog/tickets/2026-05.sql | 14 +++ CHANGELOG.md | 9 +- lib/builtin/claude/src/claude_pane.dart | 78 ++++++------ .../claude/src/pane_context_status.dart | 88 +++++--------- lib/kernel/src/focus.dart | 23 +++- lib/widgets/src/clide_marquee.dart | 112 ++++++++++++++++++ lib/widgets/src/clide_pane.dart | 83 +++++++++++++ lib/widgets/widgets.dart | 2 + .../claude/pane_context_status_test.dart | 33 +++--- test/kernel/src/focus_test.dart | 54 +++++++++ test/widgets/src/clide_marquee_test.dart | 32 +++++ test/widgets/src/clide_pane_test.dart | 78 ++++++++++++ 13 files changed, 492 insertions(+), 116 deletions(-) create mode 100644 lib/widgets/src/clide_marquee.dart create mode 100644 lib/widgets/src/clide_pane.dart create mode 100644 test/kernel/src/focus_test.dart create mode 100644 test/widgets/src/clide_marquee_test.dart create mode 100644 test/widgets/src/clide_pane_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index a266e136..2169dc20 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2198,3 +2198,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-149', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 07:14:36', '2026-05-23 07:14:36', '2026-05-23 07:14:36', NULL, '75bb1fa2d964f284910201b1c15995b9', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-149', 'status', 'in_progress', 'done', NULL, '2026-05-23 07:27:32', '2026-05-23 07:27:32', '2026-05-23 07:27:32', NULL, 'd020ddfea99355676800af1e39249c85', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-145', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 08:03:56', '2026-05-23 08:03:56', '2026-05-23 08:03:56', NULL, 'e50db235944f48a5f581ae64a3589385', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-145', 'status', 'in_progress', 'done', NULL, '2026-05-23 08:43:19', '2026-05-23 08:43:19', '2026-05-23 08:43:19', NULL, 'c9cf511becf7e542d465701e51dba4e2', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-150', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 09:04:54', '2026-05-23 09:04:54', '2026-05-23 09:04:54', NULL, 'fcc43ef1e1ae3c1dfa0e9c94ffa35bed', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 1a4dd2c5..55aabafb 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2568,3 +2568,17 @@ Applies to the lead session and every teammate. Re-validate field names against Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done). ', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-23 06:07:59', '2026-05-23 08:03:56', NULL, '917515bd4833dddf3299994c1d6739d2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-145', 'task', 'T-132', 'Per-session status strip: model / permission-mode / context%', 'A compact per-session status strip in the lead Claude pane header AND each teammate tile header (T-140), showing: current model, permission mode (accept-edits / plan / default / bypass), and context-window usage %. + +Shared status source (this ticket owns it): extract per-session status in the drift-contained reader/observer (D-75) — do NOT parse CC internals in the widgets. +- Model: team config member.model, overridden by the latest assistant message.model in the transcript when present. +- Permission mode: the transcript''s `permission-mode` records (TranscriptReader currently SKIPS these — surface the latest instead). Also honor config member.planModeRequired for teammates. +- Context %: latest assistant message.usage (input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens) over the model''s context limit. + +Delivery: emit a per-session status to the UI via the bus/events (e.g. a TeamMemberStatus event or an extra MessageBus channel) so BOTH the header strip and the T-141 sidebar render from one source without re-tailing. + +Applies to the lead session and every teammate. Re-validate field names against a live team run / CC version bump (undocumented, per T-134). + +Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done). +', 'done', 'medium', NULL, NULL, NULL, '2026-05-23 06:07:59', '2026-05-23 08:43:19', NULL, 'e7823e9d4aab062c19296bcbe5f5fc59', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-150', 'task', 'T-132', 'ClidePane primitive + focus-driven status-bar slot', 'A behavior-only pane wrapper (no chrome) every pane uses, plus the plumbing to surface a pane status widget to the bottom status bar. FocusTracker gains: activeStatusWidget (the focused pane status widget), setStatusWidget(contributionId, widget) applied ONLY when that contribution is focused (background panes ignored; they keep content locally and re-convey on focus), and clears activeStatusWidget on focus change. ClidePane(contributionId, statusWidget, active, child): when it is the shown pane (active sub-tab AND its contribution focused) it conveys statusWidget on focus-gain and on content change. New status-bar item renders focus.activeStatusWidget in a fixed-height Container that marquee-scrolls on overflow (new ClideMarquee primitive, own-the-stack, no package). Claude adopts ClidePane (statusWidget = the model/mode/context line), replacing the bus-text slot from 06cf9f8. Disconnects slot plumbing from what a pane renders: text, icon, dropdown, animation, anything.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-23 09:04:40', '2026-05-23 09:04:54', NULL, '14e4ec869de090b66ff3003f39924361', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a6f8df..7e1632a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added -- Per-session status in the bottom status bar (T-145) — the active - Claude pane publishes its model · permission mode (accept-edits / - plan / …) · context-token count to a status-bar context slot, swapping - to the focused pane on tab switch. Context is a token count, not a % +- Per-session status in the bottom status bar (T-145, T-150) — the + active Claude pane shows its model · permission mode (accept-edits / + plan / …) · context-token count in a status-bar slot, swapping to the + focused pane on tab switch and clearing when focus leaves. Long text + marquee-scrolls within the slot. Context is a token count, not a % (the transcript doesn't carry the model's window limit). - tmux agent teams surface as native teammate tiles (T-139, T-140) — when a Claude team is running, each teammate shows as a live diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 61dd3a13..22645395 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -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 createState() => _ClaudePaneState(); } @@ -63,22 +68,18 @@ class _ClaudePaneState extends State { 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 { 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 { @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 { 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, ); } } diff --git a/lib/builtin/claude/src/pane_context_status.dart b/lib/builtin/claude/src/pane_context_status.dart index 65a9d5b1..eb2d678d 100644 --- a/lib/builtin/claude/src/pane_context_status.dart +++ b/lib/builtin/claude/src/pane_context_status.dart @@ -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 createState() => _PaneContextStatusItemState(); -} - -class _PaneContextStatusItemState extends State { - StreamSubscription? _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), + ), + ), + ); + }, ); } } diff --git a/lib/kernel/src/focus.dart b/lib/kernel/src/focus.dart index 13367548..935f72f8 100644 --- a/lib/kernel/src/focus.dart +++ b/lib/kernel/src/focus.dart @@ -11,6 +11,7 @@ import 'package:flutter/widgets.dart'; class FocusTracker extends ChangeNotifier { SlotId? _slot; String? _contributionId; + Widget? _activeStatusWidget; final Map _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(); } diff --git a/lib/widgets/src/clide_marquee.dart b/lib/widgets/src/clide_marquee.dart new file mode 100644 index 00000000..63755540 --- /dev/null +++ b/lib/widgets/src/clide_marquee.dart @@ -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 createState() => _ClideMarqueeState(); +} + +class _ClideMarqueeState extends State 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], + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/src/clide_pane.dart b/lib/widgets/src/clide_pane.dart new file mode 100644 index 00000000..4a42abfe --- /dev/null +++ b/lib/widgets/src/clide_pane.dart @@ -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 createState() => _ClidePaneState(); +} + +class _ClidePaneState extends State { + 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; +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 9e3432bd..a38e2aa6 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/test/builtin/claude/pane_context_status_test.dart b/test/builtin/claude/pane_context_status_test.dart index 79997316..a0a1a21e 100644 --- a/test/builtin/claude/pane_context_status_test.dart +++ b/test/builtin/claude/pane_context_status_test.dart @@ -1,9 +1,11 @@ -/// Tests for the status-bar in-pane context slot (T-145): it shows the -/// latest text published on the bus channel and clears on empty. +/// Tests for the focus-driven status-bar slot item (T-150): it renders +/// the focused pane's status widget and clears when focus moves to a +/// pane with no status. library; import 'package:clide/builtin/claude/src/pane_context_status.dart'; -import 'package:clide/widgets/widgets.dart'; +import 'package:clide/kernel/src/panels/slot_id.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; @@ -14,27 +16,20 @@ void main() { setUp(() async => f = await KernelFixture.create()); tearDown(() => f.dispose()); - testWidgets('renders the latest published context, clears on empty', (tester) async { + testWidgets('renders the focused pane status, clears on focus change', (tester) async { await tester.pumpWidget(harness(f, const PaneContextStatusItem())); await tester.pump(); - expect(find.byType(ClideText), findsNothing); // nothing published yet + expect(find.text('S'), findsNothing); // nothing focused - publishPaneContext(f.services.messages, 'builtin.claude', 'opus 4.7 · default · 21k ctx'); + final focus = f.services.focus; + focus.setActive(slot: Slots.workspace, contributionId: 'pane.x'); + focus.setStatusWidget('pane.x', const Text('S', textDirection: TextDirection.ltr)); await tester.pump(); - await tester.pump(); - expect(find.textContaining('opus 4.7'), findsOneWidget); + expect(find.text('S'), findsOneWidget); - // A newer publisher overwrites the slot. - publishPaneContext(f.services.messages, 'builtin.editor', 'lib/app.dart · modified'); + // Focus a pane with no status → the slot clears. + focus.setActive(slot: Slots.workspace, contributionId: 'pane.y'); await tester.pump(); - await tester.pump(); - expect(find.textContaining('opus 4.7'), findsNothing); - expect(find.textContaining('lib/app.dart'), findsOneWidget); - - // Empty clears it. - publishPaneContext(f.services.messages, 'builtin.editor', ''); - await tester.pump(); - await tester.pump(); - expect(find.byType(ClideText), findsNothing); + expect(find.text('S'), findsNothing); }); } diff --git a/test/kernel/src/focus_test.dart b/test/kernel/src/focus_test.dart new file mode 100644 index 00000000..354a56f2 --- /dev/null +++ b/test/kernel/src/focus_test.dart @@ -0,0 +1,54 @@ +/// Tests for FocusTracker's status-widget slot (T-150). +library; + +import 'package:clide/kernel/src/focus.dart'; +import 'package:clide/kernel/src/panels/slot_id.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FocusTracker status widget', () { + test('setStatusWidget applies only for the focused contribution', () { + final f = FocusTracker(); + f.setActive(slot: Slots.workspace, contributionId: 'a'); + const wa = SizedBox(key: ValueKey('a')); + f.setStatusWidget('a', wa); + expect(f.activeStatusWidget, wa); + + // A background contribution's update is ignored. + f.setStatusWidget('b', const SizedBox(key: ValueKey('b'))); + expect(f.activeStatusWidget, wa); + }); + + test('a focus change clears the previous status widget', () { + final f = FocusTracker(); + f.setActive(slot: Slots.workspace, contributionId: 'a'); + f.setStatusWidget('a', const SizedBox()); + expect(f.activeStatusWidget, isNotNull); + + f.setActive(slot: Slots.workspace, contributionId: 'b'); + expect(f.activeStatusWidget, isNull); + }); + + test('clear() resets focus and status', () { + final f = FocusTracker(); + f.setActive(slot: Slots.workspace, contributionId: 'a'); + f.setStatusWidget('a', const SizedBox()); + f.clear(); + expect(f.activeContributionId, isNull); + expect(f.activeStatusWidget, isNull); + }); + + test('setStatusWidget notifies on change, not on a repeat', () { + final f = FocusTracker(); + f.setActive(slot: Slots.workspace, contributionId: 'a'); + var n = 0; + f.addListener(() => n++); + const w = SizedBox(); + f.setStatusWidget('a', w); + expect(n, 1); + f.setStatusWidget('a', w); // identical → no notify + expect(n, 1); + }); + }); +} diff --git a/test/widgets/src/clide_marquee_test.dart b/test/widgets/src/clide_marquee_test.dart new file mode 100644 index 00000000..96b61cf0 --- /dev/null +++ b/test/widgets/src/clide_marquee_test.dart @@ -0,0 +1,32 @@ +/// Tests for ClideMarquee (T-150): static when the child fits, scrolls +/// (looped copy) when it overflows; clips its box. +library; + +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _boxed(double width, Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox(width: width, height: 16, child: child), + ), + ); + +void main() { + testWidgets('shows the child statically when it fits', (tester) async { + await tester.pumpWidget(_boxed(300, const ClideMarquee(child: Text('short')))); + await tester.pump(); + expect(find.text('short'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); // tear down the ticker + }); + + testWidgets('renders a looped copy and runs without overflow when wider than the slot', (tester) async { + await tester.pumpWidget(_boxed(40, const ClideMarquee(child: Text('a long status line that overflows the slot')))); + await tester.pump(); // measure + await tester.pump(const Duration(milliseconds: 100)); // advance the ticker + expect(find.text('a long status line that overflows the slot'), findsWidgets); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); // dispose → stop ticker + }); +} diff --git a/test/widgets/src/clide_pane_test.dart b/test/widgets/src/clide_pane_test.dart new file mode 100644 index 00000000..3df6f6f0 --- /dev/null +++ b/test/widgets/src/clide_pane_test.dart @@ -0,0 +1,78 @@ +/// Tests for ClidePane (T-150): it surfaces its statusWidget to the +/// FocusTracker slot only while it is the shown (focused + active) pane. +library; + +import 'package:clide/kernel/src/panels/slot_id.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('conveys its status while its contribution is focused, clears on blur', (tester) async { + await tester.pumpWidget(harness( + f, + const ClidePane( + contributionId: 'pane.x', + statusWidget: Text('S', textDirection: TextDirection.ltr), + child: SizedBox(), + ), + )); + final focus = f.services.focus; + expect(focus.activeStatusWidget, isNull); // not focused yet + + focus.setActive(slot: Slots.workspace, contributionId: 'pane.x'); + await tester.pump(); + expect(focus.activeStatusWidget, isA()); + + focus.setActive(slot: Slots.workspace, contributionId: 'other'); + await tester.pump(); + expect(focus.activeStatusWidget, isNull); // focus moved away → cleared + }); + + testWidgets('an inactive pane never conveys', (tester) async { + await tester.pumpWidget(harness( + f, + const ClidePane( + contributionId: 'pane.x', + active: false, + statusWidget: Text('S', textDirection: TextDirection.ltr), + child: SizedBox(), + ), + )); + f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x'); + await tester.pump(); + expect(f.services.focus.activeStatusWidget, isNull); + }); + + testWidgets('re-conveys when statusWidget changes while focused', (tester) async { + f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x'); + var label = 'A'; + late StateSetter setLabel; + await tester.pumpWidget(harness( + f, + StatefulBuilder( + builder: (ctx, setState) { + setLabel = setState; + return ClidePane( + contributionId: 'pane.x', + statusWidget: Text(label, textDirection: TextDirection.ltr), + child: const SizedBox(), + ); + }, + ), + )); + await tester.pump(); + expect((f.services.focus.activeStatusWidget! as Text).data, 'A'); + + setLabel(() => label = 'B'); + await tester.pump(); + expect((f.services.focus.activeStatusWidget! as Text).data, 'B'); + }); +}