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