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
+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,
),
);
}
}