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
@@ -0,0 +1,58 @@
/// 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);
});
});
}
@@ -534,6 +534,58 @@ void main() {
});
// -------------------------------------------------------------------------
group('SessionStatus (T-145)', () {
test('parseTranscriptChunk extracts model, permission-mode, context tokens', () {
final chunk = [
jsonEncode({'type': 'permission-mode', 'permissionMode': 'plan', 'sessionId': 's'}),
jsonEncode({
'type': 'assistant',
'uuid': 'a1',
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:06.708Z',
'message': {
'role': 'assistant',
'model': 'claude-opus-4-7',
'content': [
{'type': 'text', 'text': 'hi'}
],
'usage': {
'input_tokens': 2,
'cache_read_input_tokens': 1000,
'cache_creation_input_tokens': 500,
'output_tokens': 99,
},
},
}),
].join('\n');
final parsed = parseTranscriptChunk(chunk);
expect(parsed.status.permissionMode, 'plan');
expect(parsed.status.model, 'claude-opus-4-7');
expect(parsed.status.contextTokens, 1502); // input 2 + read 1000 + create 500 (not output)
// The assistant text item is still emitted alongside.
expect(parsed.items.whereType<AssistantTextMessage>(), hasLength(1));
});
test('empty chunk yields an empty status', () {
expect(parseTranscriptChunk('').status.isEmpty, isTrue);
});
test('merge overlays non-null fields only', () {
const a = SessionStatus(model: 'm1', permissionMode: 'default');
const b = SessionStatus(permissionMode: 'plan', contextTokens: 10);
final m = a.merge(b);
expect(m.model, 'm1'); // kept — b.model is null
expect(m.permissionMode, 'plan'); // overlaid
expect(m.contextTokens, 10);
});
test('equality compares all fields', () {
expect(const SessionStatus(model: 'x'), const SessionStatus(model: 'x'));
expect(const SessionStatus(model: 'x'), isNot(const SessionStatus(model: 'y')));
});
});
group('TranscriptReader — append streaming (filesystem)', () {
late Directory tempBase;
@@ -772,6 +824,42 @@ void main() {
await reader.dispose();
expect(collected.whereType<AssistantTextMessage>().single.text, 'arrived late');
});
test('statusStream emits model / permission-mode / context tokens', () async {
final projectDir = mungedDir(tempBase, workspace);
await projectDir.create(recursive: true);
File('${projectDir.path}/session-abc.jsonl').writeAsStringSync(
'${[
jsonEncode({'type': 'permission-mode', 'permissionMode': 'acceptEdits', 'sessionId': 's'}),
jsonEncode({
'type': 'assistant',
'uuid': 'a1',
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:06.708Z',
'message': {
'role': 'assistant',
'model': 'claude-sonnet-4-6',
'content': [
{'type': 'text', 'text': 'hi'}
],
'usage': {'input_tokens': 5, 'cache_read_input_tokens': 200, 'cache_creation_input_tokens': 0, 'output_tokens': 10},
},
}),
].join('\n')}\n',
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final statuses = <SessionStatus>[];
final sub = reader.statusStream.listen(statuses.add);
await pumpUntil(() => statuses.isNotEmpty && statuses.last.model != null && statuses.last.permissionMode != null);
await sub.cancel();
await reader.dispose();
expect(statuses.last.permissionMode, 'acceptEdits');
expect(statuses.last.model, 'claude-sonnet-4-6');
expect(statuses.last.contextTokens, 205);
});
});
}