diff --git a/CHANGELOG.md b/CHANGELOG.md index 5503321b..54a6f8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added -- Per-session status strip in the Claude pane (T-145) — current model, - permission mode (accept-edits / plan / …), and context-window token - count above the conversation, updated live from the transcript. Context - is a token count, not a %, since the transcript doesn't carry the - model's window limit. +- 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 % + (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 conversation tile beside the lead in a grid that wraps 1→2→3 columns, diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 00553d1e..61dd3a13 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -9,12 +9,13 @@ import 'package:flutter/widgets.dart'; import 'claude_banner.dart'; import 'claude_composer.dart'; -import 'claude_status_strip.dart'; +import 'claude_status.dart'; import 'clipboard_paste.dart'; 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'; @@ -24,12 +25,17 @@ class ClaudePane extends StatefulWidget { this.isPrimary = true, this.secondaryIndex, this.showChrome = true, + this.active = true, }) : 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). + final bool active; + @override State createState() => _ClaudePaneState(); } @@ -57,6 +63,24 @@ 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(); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -256,7 +280,9 @@ class _ClaudePaneState extends State { ); _conversation = ConversationController.fromBus(messages: messages, channel: channel); _statusSub = _feed!.statusStream.listen((s) { - if (mounted) setState(() => _status = s); + if (!mounted) return; + setState(() => _status = s); + _publishContext(); }); _subscribe(); setState(() {}); @@ -329,7 +355,6 @@ class _ClaudePaneState extends State { } else if (_conversation != null) { body = Column( children: [ - if (!_status.isEmpty) ClaudeStatusStrip(status: _status), Expanded( child: ConversationView( controller: _conversation!, diff --git a/lib/builtin/claude/src/claude_session_host.dart b/lib/builtin/claude/src/claude_session_host.dart index 9d2d294c..244bfbca 100644 --- a/lib/builtin/claude/src/claude_session_host.dart +++ b/lib/builtin/claude/src/claude_session_host.dart @@ -69,6 +69,8 @@ class ClaudeSessionHostState extends State { // The MultitabPane already provides the tab strip header; // suppressing the ClaudePane's own chrome avoids a double row. showChrome: false, + // Only the visible sub-tab publishes to the status-bar slot. + active: entry.id == _controller.activeId, ); }, ); diff --git a/lib/builtin/claude/src/claude_status.dart b/lib/builtin/claude/src/claude_status.dart new file mode 100644 index 00000000..c76b6671 --- /dev/null +++ b/lib/builtin/claude/src/claude_status.dart @@ -0,0 +1,51 @@ +/// Formatting for the per-session status line (T-145): model, permission +/// mode, and context-window token count, joined for the status-bar slot. +/// +/// Context is a token *count*, not a percentage — the transcript carries +/// `message.usage` but not the model's context limit, and the model id +/// doesn't encode the 1M vs 200k tier, so a percentage would be guesswork. +library; + +import 'package:clide/builtin/claude/src/transcript_reader.dart'; + +/// Build the status-bar line, e.g. `opus 4.7 · default · 21k ctx`. +/// Empty string when there's nothing to show. +String formatStatusLine(SessionStatus status) { + final parts = [ + if (status.model != null) shortModelLabel(status.model!), + if (status.permissionMode != null) permissionModeLabel(status.permissionMode!), + if (status.contextTokens != null) '${formatTokenCount(status.contextTokens!)} ctx', + ]; + return parts.join(' · '); +} + +/// `claude-opus-4-7` → `opus 4.7`; unknown shapes pass through. +String shortModelLabel(String model) { + final s = model.startsWith('claude-') ? model.substring('claude-'.length) : model; + final parts = s.split('-'); + if (parts.length >= 2) return '${parts.first} ${parts.sublist(1).join('.')}'; + return s; +} + +/// Friendly label for Claude's permission modes. +String permissionModeLabel(String mode) { + switch (mode) { + case 'acceptEdits': + return 'accept-edits'; + case 'bypassPermissions': + return 'bypass'; + case 'plan': + return 'plan'; + case 'default': + return 'default'; + default: + return mode; + } +} + +/// Compact token count: `765k`, `1.2M`, or the raw number under 1k. +String formatTokenCount(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) return '${(n / 1000).round()}k'; + return '$n'; +} diff --git a/lib/builtin/claude/src/claude_status_strip.dart b/lib/builtin/claude/src/claude_status_strip.dart deleted file mode 100644 index c5e3081b..00000000 --- a/lib/builtin/claude/src/claude_status_strip.dart +++ /dev/null @@ -1,75 +0,0 @@ -/// A thin per-session status strip (T-145): current model, permission -/// mode, and context-window token count, shown above the conversation. -/// -/// Context is a token *count*, not a percentage — the transcript carries -/// `message.usage` but not the model's context limit, and the model id -/// doesn't encode the 1M vs 200k tier, so a percentage would be guesswork. -library; - -import 'package:clide/builtin/claude/src/transcript_reader.dart'; -import 'package:clide/kernel/src/theme/controller.dart'; -import 'package:clide/widgets/widgets.dart'; -import 'package:flutter/widgets.dart'; - -class ClaudeStatusStrip extends StatelessWidget { - const ClaudeStatusStrip({super.key, required this.status}); - - final SessionStatus status; - - @override - Widget build(BuildContext context) { - final tokens = ClideTheme.of(context).surface; - final parts = [ - if (status.model != null) shortModelLabel(status.model!), - if (status.permissionMode != null) permissionModeLabel(status.permissionMode!), - if (status.contextTokens != null) '${formatTokenCount(status.contextTokens!)} ctx', - ]; - if (parts.isEmpty) return const SizedBox.shrink(); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: tokens.panelBorder)), - ), - child: ClideText( - parts.join(' · '), - fontSize: clideFontSmall, - muted: true, - fontFamily: clideMonoFamily, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ); - } -} - -/// `claude-opus-4-7` → `opus 4.7`; unknown shapes pass through. -String shortModelLabel(String model) { - final s = model.startsWith('claude-') ? model.substring('claude-'.length) : model; - final parts = s.split('-'); - if (parts.length >= 2) return '${parts.first} ${parts.sublist(1).join('.')}'; - return s; -} - -/// Friendly label for Claude's permission modes. -String permissionModeLabel(String mode) { - switch (mode) { - case 'acceptEdits': - return 'accept-edits'; - case 'bypassPermissions': - return 'bypass'; - case 'plan': - return 'plan'; - case 'default': - return 'default'; - default: - return mode; - } -} - -/// Compact token count: `765k`, `1.2M`, or the raw number under 1k. -String formatTokenCount(int n) { - if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; - if (n >= 1000) return '${(n / 1000).round()}k'; - return '$n'; -} diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 8e95de79..cc69b5ed 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:clide/clide.dart'; import 'package:clide/builtin/claude/src/claude_session_host.dart'; import 'package:clide/builtin/claude/src/session_naming.dart'; +import 'package:clide/builtin/claude/src/pane_context_status.dart'; import 'package:clide/builtin/claude/src/team_observer.dart'; import 'package:clide/builtin/claude/src/team_panel_host.dart'; import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux; @@ -52,6 +53,13 @@ class ClaudeExtension extends ClideExtension { title: 'Claude: kill all tmux sessions for this repo', run: _killAllSessions, ), + // In-pane status slot (T-145): the active Claude pane publishes + // its model · permission-mode · context line here. + StatusItemContribution( + id: 'claude.status-context', + priority: 50, + build: (_) => const PaneContextStatusItem(), + ), ]; @override diff --git a/lib/builtin/claude/src/pane_context_status.dart b/lib/builtin/claude/src/pane_context_status.dart new file mode 100644 index 00000000..65a9d5b1 --- /dev/null +++ b/lib/builtin/claude/src/pane_context_status.dart @@ -0,0 +1,74 @@ +/// 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. +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'; + +/// Publish [text] to the context slot (empty string clears it). +void publishPaneContext(MessageBus messages, String publisher, String text) { + messages.publish(publisher, paneContextChannel, {'text': text}); +} + +/// 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 { + 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, + ), + ); + } +} diff --git a/test/builtin/claude/claude_status_strip_test.dart b/test/builtin/claude/claude_status_strip_test.dart deleted file mode 100644 index 4cb0e20d..00000000 --- a/test/builtin/claude/claude_status_strip_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -/// Tests for the per-session status strip (T-145): formatters and the -/// widget's render of model · permission-mode · context. -library; - -import 'package:clide/builtin/claude/src/claude_status_strip.dart'; -import 'package:clide/builtin/claude/src/transcript_reader.dart'; -import 'package:clide/widgets/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../helpers/kernel_fixture.dart'; -import '../../helpers/widget_harness.dart'; - -void main() { - group('status formatters', () { - test('shortModelLabel strips the claude- prefix and dots the version', () { - expect(shortModelLabel('claude-opus-4-7'), 'opus 4.7'); - expect(shortModelLabel('claude-sonnet-4-6'), 'sonnet 4.6'); - expect(shortModelLabel('weird'), 'weird'); - }); - - test('permissionModeLabel humanises CC modes', () { - expect(permissionModeLabel('acceptEdits'), 'accept-edits'); - expect(permissionModeLabel('bypassPermissions'), 'bypass'); - expect(permissionModeLabel('plan'), 'plan'); - expect(permissionModeLabel('default'), 'default'); - expect(permissionModeLabel('something-new'), 'something-new'); - }); - - test('formatTokenCount uses k / M / raw', () { - expect(formatTokenCount(500), '500'); - expect(formatTokenCount(765000), '765k'); - expect(formatTokenCount(1200000), '1.2M'); - }); - }); - - group('ClaudeStatusStrip', () { - late KernelFixture f; - setUp(() async => f = await KernelFixture.create()); - tearDown(() => f.dispose()); - - testWidgets('renders model, mode, and context', (tester) async { - await tester.pumpWidget(harness( - f, - const ClaudeStatusStrip( - status: SessionStatus(model: 'claude-opus-4-7', permissionMode: 'acceptEdits', contextTokens: 765000), - ), - )); - expect(find.textContaining('opus 4.7'), findsOneWidget); - expect(find.textContaining('accept-edits'), findsOneWidget); - expect(find.textContaining('765k ctx'), findsOneWidget); - }); - - testWidgets('empty status renders nothing', (tester) async { - await tester.pumpWidget(harness(f, const ClaudeStatusStrip(status: SessionStatus()))); - expect(find.byType(ClideText), findsNothing); - }); - }); -} diff --git a/test/builtin/claude/claude_status_test.dart b/test/builtin/claude/claude_status_test.dart new file mode 100644 index 00000000..fc7b5a2a --- /dev/null +++ b/test/builtin/claude/claude_status_test.dart @@ -0,0 +1,42 @@ +/// Tests for the per-session status line formatting (T-145). +library; + +import 'package:clide/builtin/claude/src/claude_status.dart'; +import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:test/test.dart'; + +void main() { + group('status formatters', () { + test('shortModelLabel strips the claude- prefix and dots the version', () { + expect(shortModelLabel('claude-opus-4-7'), 'opus 4.7'); + expect(shortModelLabel('claude-sonnet-4-6'), 'sonnet 4.6'); + expect(shortModelLabel('weird'), 'weird'); + }); + + test('permissionModeLabel humanises CC modes', () { + expect(permissionModeLabel('acceptEdits'), 'accept-edits'); + expect(permissionModeLabel('bypassPermissions'), 'bypass'); + expect(permissionModeLabel('plan'), 'plan'); + expect(permissionModeLabel('default'), 'default'); + expect(permissionModeLabel('something-new'), 'something-new'); + }); + + test('formatTokenCount uses k / M / raw', () { + expect(formatTokenCount(500), '500'); + expect(formatTokenCount(765000), '765k'); + expect(formatTokenCount(1200000), '1.2M'); + }); + }); + + group('formatStatusLine', () { + test('joins the present fields', () { + const s = SessionStatus(model: 'claude-opus-4-7', permissionMode: 'acceptEdits', contextTokens: 21000); + expect(formatStatusLine(s), 'opus 4.7 · accept-edits · 21k ctx'); + }); + + test('omits absent fields', () { + expect(formatStatusLine(const SessionStatus(model: 'claude-sonnet-4-6')), 'sonnet 4.6'); + expect(formatStatusLine(const SessionStatus()), ''); + }); + }); +} diff --git a/test/builtin/claude/pane_context_status_test.dart b/test/builtin/claude/pane_context_status_test.dart new file mode 100644 index 00000000..79997316 --- /dev/null +++ b/test/builtin/claude/pane_context_status_test.dart @@ -0,0 +1,40 @@ +/// 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. +library; + +import 'package:clide/builtin/claude/src/pane_context_status.dart'; +import 'package:clide/widgets/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('renders the latest published context, clears on empty', (tester) async { + await tester.pumpWidget(harness(f, const PaneContextStatusItem())); + await tester.pump(); + expect(find.byType(ClideText), findsNothing); // nothing published yet + + publishPaneContext(f.services.messages, 'builtin.claude', 'opus 4.7 · default · 21k ctx'); + await tester.pump(); + await tester.pump(); + expect(find.textContaining('opus 4.7'), findsOneWidget); + + // A newer publisher overwrites the slot. + publishPaneContext(f.services.messages, 'builtin.editor', 'lib/app.dart · modified'); + 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); + }); +}