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