move the per-session status to the bottom status bar (T-145)

Per feedback, the model · permission-mode · context line reads better in
the status bar than as a strip above the conversation. Adds a generic,
publisher-agnostic status-bar context slot: a pane publishes a short
string to the `statusbar.context` MessageBus channel and the bar shows
the latest. The active Claude sub-tab publishes (inactive panes stay
quiet, so no race); switching tabs swaps the slot to the focused pane.

Replaces the in-pane ClaudeStatusStrip with a formatStatusLine helper +
PaneContextStatusItem (the status-bar widget) and a StatusItemContribution.
ClaudeSessionHost passes `active` so only the visible sub-tab publishes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 10:37:16 +02:00
co-authored by Claude Opus 4.7
parent 06cf9f8298
commit b0d5aa36f4
10 changed files with 250 additions and 141 deletions
+5 -5
View File
@@ -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,
+28 -3
View File
@@ -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<ClaudePane> createState() => _ClaudePaneState();
}
@@ -57,6 +63,24 @@ 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();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -256,7 +280,9 @@ class _ClaudePaneState extends State<ClaudePane> {
);
_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<ClaudePane> {
} else if (_conversation != null) {
body = Column(
children: [
if (!_status.isEmpty) ClaudeStatusStrip(status: _status),
Expanded(
child: ConversationView(
controller: _conversation!,
@@ -69,6 +69,8 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
// 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,
);
},
);
+51
View File
@@ -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';
}
@@ -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';
}
+8
View File
@@ -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
@@ -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<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,
),
);
}
}
@@ -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);
});
});
}
@@ -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()), '');
});
});
}
@@ -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);
});
}