add per-session status strip: model / permission-mode / context (T-145)
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 23s

The transcript reader now also extracts a SessionStatus — current model
(assistant message.model), permission mode (the permission-mode records,
previously skipped), and context-window tokens (message.usage input +
cache-read + cache-creation) — and emits it on a statusStream, merging
deltas so it only fires on change. All CC-internals parsing stays in the
drift-contained reader (D-75).

The Claude pane renders this as a thin strip above the conversation
(model · permission-mode · context). Context is shown as a token count,
not a percentage: the transcript carries usage but not the model's window
limit, and the model id doesn't encode the 1M vs 200k tier.

Lead pane done; teammate-tile mirror and the sidebar (T-141) consume the
same status next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 10:14:05 +02:00
co-authored by Claude Opus 4.7
parent 4b4d911734
commit 06cf9f8298
9 changed files with 344 additions and 6 deletions
+9
View File
@@ -9,6 +9,7 @@ import 'package:flutter/widgets.dart';
import 'claude_banner.dart';
import 'claude_composer.dart';
import 'claude_status_strip.dart';
import 'clipboard_paste.dart';
import 'conversation_controller.dart';
import 'conversation_view.dart';
@@ -42,8 +43,10 @@ class _ClaudePaneState extends State<ClaudePane> {
static String? _tmuxConfPath;
StreamSubscription<DaemonEvent>? _eventSub;
StreamSubscription<SessionStatus>? _statusSub;
ConversationController? _conversation;
TranscriptPublisher? _feed;
SessionStatus _status = const SessionStatus();
String? _paneId;
String? _sessionName;
String? _sessionId;
@@ -71,6 +74,8 @@ class _ClaudePaneState extends State<ClaudePane> {
_conversation = null;
unawaited(_feed?.dispose());
_feed = null;
_statusSub?.cancel();
_statusSub = null;
_eventSub?.cancel();
_eventSub = null;
final id = _paneId;
@@ -250,6 +255,9 @@ class _ClaudePaneState extends State<ClaudePane> {
channel: channel,
);
_conversation = ConversationController.fromBus(messages: messages, channel: channel);
_statusSub = _feed!.statusStream.listen((s) {
if (mounted) setState(() => _status = s);
});
_subscribe();
setState(() {});
}
@@ -321,6 +329,7 @@ class _ClaudePaneState extends State<ClaudePane> {
} else if (_conversation != null) {
body = Column(
children: [
if (!_status.isEmpty) ClaudeStatusStrip(status: _status),
Expanded(
child: ConversationView(
controller: _conversation!,
@@ -0,0 +1,75 @@
/// 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';
}
@@ -56,6 +56,10 @@ class TranscriptPublisher {
final String channel;
late final StreamSubscription<ConversationItem> _sub;
/// Live session status (model / permission-mode / context) from the
/// underlying reader — passed through for the status strip (T-145).
Stream<SessionStatus> get statusStream => _reader.statusStream;
/// Stops publishing and tears down the underlying reader.
Future<void> dispose() async {
await _sub.cancel();
+91 -6
View File
@@ -218,6 +218,8 @@ class TranscriptReader {
}
StreamController<ConversationItem>? _controller;
final StreamController<SessionStatus> _statusController = StreamController<SessionStatus>.broadcast();
SessionStatus _status = const SessionStatus();
Timer? _timer;
String? _currentPath;
int _cursor = 0;
@@ -267,10 +269,19 @@ class TranscriptReader {
return _controller!.stream;
}
/// Live [SessionStatus] updates (model / permission-mode / context
/// tokens), emitted only when a value changes (T-145). Starts polling
/// too, so a status-only consumer still drives the tail.
Stream<SessionStatus> get statusStream {
_controller ??= _start();
return _statusController.stream;
}
/// Cancels polling and closes the underlying stream.
Future<void> dispose() async {
_timer?.cancel();
_timer = null;
unawaited(_statusController.close());
await _controller?.close();
_controller = null;
}
@@ -349,6 +360,14 @@ class TranscriptReader {
if (controller.isClosed) break;
controller.add(item);
}
// Fold this chunk's status deltas into the running status; emit only
// on change so listeners (the status strip) don't churn.
final merged = _status.merge(parsed.status);
if (merged != _status) {
_status = merged;
if (!_statusController.isClosed) _statusController.add(_status);
}
}
/// Parse a single JSONL line into its items (forwarding any version
@@ -362,8 +381,50 @@ class TranscriptReader {
}
}
/// Result of [parseTranscriptChunk]: parsed items + version-drift warnings.
typedef ParsedChunk = ({List<ConversationItem> items, List<String> warnings});
/// Live per-session status surfaced for the status strip / sidebar
/// (T-145). All fields nullable — a chunk only carries what it saw, and
/// the reader [merge]s deltas into a running status.
class SessionStatus {
const SessionStatus({this.model, this.permissionMode, this.contextTokens});
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
final String? model;
/// Latest `permissionMode` (default / acceptEdits / plan / bypassPermissions).
final String? permissionMode;
/// Input context-window tokens in the most recent assistant turn
/// (input + cache-read + cache-creation). A count, not a percentage —
/// the transcript doesn't carry the model's context limit.
final int? contextTokens;
bool get isEmpty => model == null && permissionMode == null && contextTokens == null;
/// Overlay [other]'s non-null fields onto this one.
SessionStatus merge(SessionStatus other) => SessionStatus(
model: other.model ?? model,
permissionMode: other.permissionMode ?? permissionMode,
contextTokens: other.contextTokens ?? contextTokens,
);
@override
bool operator ==(Object other) =>
other is SessionStatus && other.model == model && other.permissionMode == permissionMode && other.contextTokens == contextTokens;
@override
int get hashCode => Object.hash(model, permissionMode, contextTokens);
}
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and
/// the latest [SessionStatus] deltas seen in the chunk.
typedef ParsedChunk = ({List<ConversationItem> items, List<String> warnings, SessionStatus status});
class _StatusAcc {
String? model;
String? permissionMode;
int? contextTokens;
SessionStatus toStatus() => SessionStatus(model: model, permissionMode: permissionMode, contextTokens: contextTokens);
}
// ---------------------------------------------------------------------------
// Parsing — pure + isolate-safe. Top-level (no instance state) so it can run
@@ -374,15 +435,16 @@ typedef ParsedChunk = ({List<ConversationItem> items, List<String> warnings});
ParsedChunk parseTranscriptChunk(String chunk) {
final items = <ConversationItem>[];
final warnings = <String>[];
final status = _StatusAcc();
for (final raw in chunk.split('\n')) {
final line = raw.trim();
if (line.isEmpty) continue;
_parseLineInto(line, items, warnings);
_parseLineInto(line, items, warnings, status);
}
return (items: items, warnings: warnings);
return (items: items, warnings: warnings, status: status.toStatus());
}
void _parseLineInto(String line, List<ConversationItem> out, List<String> warnings) {
void _parseLineInto(String line, List<ConversationItem> out, List<String> warnings, _StatusAcc status) {
Map<String, dynamic> envelope;
try {
envelope = (jsonDecode(line) as Map).cast<String, dynamic>();
@@ -402,7 +464,16 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
}
final type = envelope['type'] as String?;
if (type == null || _skipTypes.contains(type)) return;
if (type == null) return;
// Status extraction runs for skip-types too (permission-mode is skipped
// as an item but carries the current mode).
if (type == 'permission-mode') {
final pm = envelope['permissionMode'] as String?;
if (pm != null && pm.isNotEmpty) status.permissionMode = pm;
}
if (_skipTypes.contains(type)) return;
final uuid = envelope['uuid'] as String? ?? '';
final isSidechain = envelope['isSidechain'] as bool? ?? false;
@@ -419,11 +490,25 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
_parseUserInto(envelope, uuid, timestamp, isSidechain, out);
case 'assistant':
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, out);
_extractAssistantStatus(envelope, status);
default:
break; // unknown type — degrade gracefully
}
}
/// Capture model + context tokens from an assistant turn's message.
void _extractAssistantStatus(Map<String, dynamic> envelope, _StatusAcc status) {
final message = envelope['message'] as Map?;
if (message == null) return;
final model = message['model'] as String?;
if (model != null && model.isNotEmpty) status.model = model;
final usage = message['usage'] as Map?;
if (usage != null) {
int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
status.contextTokens = n('input_tokens') + n('cache_read_input_tokens') + n('cache_creation_input_tokens');
}
}
void _parseUserInto(
Map<String, dynamic> envelope,
String uuid,