diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index d37638ee..a266e136 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2197,3 +2197,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-147', 'status', 'in_progress', 'done', NULL, '2026-05-23 07:06:54', '2026-05-23 07:06:54', '2026-05-23 07:06:54', NULL, 'b6b88954fdc04ecf7847dea204a92e14', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-149', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 07:14:36', '2026-05-23 07:14:36', '2026-05-23 07:14:36', NULL, '75bb1fa2d964f284910201b1c15995b9', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-149', 'status', 'in_progress', 'done', NULL, '2026-05-23 07:27:32', '2026-05-23 07:27:32', '2026-05-23 07:27:32', NULL, 'd020ddfea99355676800af1e39249c85', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-145', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 08:03:56', '2026-05-23 08:03:56', '2026-05-23 08:03:56', NULL, 'e50db235944f48a5f581ae64a3589385', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index a42f4738..1a4dd2c5 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2555,3 +2555,16 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-147', 'bug', 'T-132', 'Claude pane gets stuck waiting on a stale/legacy session', 'When the bound transcript (/.jsonl) never appears — e.g. attaching via new-session -A to a session created before --session-id binding (T-146), or any unconnectable session — the pane shows ''Waiting for Claude…'' forever. Fix: before spawning, if the bound transcript for our deterministic session id does not exist, kill the clide tmux session of that exact name so new-session creates a clean session with our --session-id. SAFETY (hard requirement): only ever kill clide-initiated sessions — by exact clide-claude- name, on the private -L clide socket (a user''s terminal claude is never on that socket); never delete any transcript file. Also makes clean-install/first-run robust. Live-verify the stuck primary self-heals on relaunch and a secondary still gets a fresh independent session.', 'done', 'high', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 07:06:54', NULL, '4c08227959a8fe6905521ebdc42487c1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-149', 'task', 'T-132', 'Native Claude pane startup banner (empty state)', 'Replace the bland ''Waiting for Claude…'' empty state with a native clide banner: clide logo, a Claude label (brand orange), the session role (primary/secondary N), the workspace path (home-collapsed), and the tmux session/status line, plus a ''warming up'' hint. Owned/native — no tmux capture-pane, no CC-internals coupling. ConversationView gains an optional emptyState widget; ClaudePane supplies a ClaudeBanner built from data it already has (repoRoot, statusLine, isPrimary).', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 07:14:36', '2026-05-23 07:14:36', NULL, '741b021184df1596b364aa2d09167e85', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-149', 'task', 'T-132', 'Native Claude pane startup banner (empty state)', 'Replace the bland ''Waiting for Claude…'' empty state with a native clide banner: clide logo, a Claude label (brand orange), the session role (primary/secondary N), the workspace path (home-collapsed), and the tmux session/status line, plus a ''warming up'' hint. Owned/native — no tmux capture-pane, no CC-internals coupling. ConversationView gains an optional emptyState widget; ClaudePane supplies a ClaudeBanner built from data it already has (repoRoot, statusLine, isPrimary).', 'done', 'low', NULL, NULL, NULL, '2026-05-23 07:14:36', '2026-05-23 07:27:32', NULL, '1d922f79ed8f98bea75042af8662dd74', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-145', 'task', 'T-132', 'Per-session status strip: model / permission-mode / context%', 'A compact per-session status strip in the lead Claude pane header AND each teammate tile header (T-140), showing: current model, permission mode (accept-edits / plan / default / bypass), and context-window usage %. + +Shared status source (this ticket owns it): extract per-session status in the drift-contained reader/observer (D-75) — do NOT parse CC internals in the widgets. +- Model: team config member.model, overridden by the latest assistant message.model in the transcript when present. +- Permission mode: the transcript''s `permission-mode` records (TranscriptReader currently SKIPS these — surface the latest instead). Also honor config member.planModeRequired for teammates. +- Context %: latest assistant message.usage (input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens) over the model''s context limit. + +Delivery: emit a per-session status to the UI via the bus/events (e.g. a TeamMemberStatus event or an extra MessageBus channel) so BOTH the header strip and the T-141 sidebar render from one source without re-tailing. + +Applies to the lead session and every teammate. Re-validate field names against a live team run / CC version bump (undocumented, per T-134). + +Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done). +', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-23 06:07:59', '2026-05-23 08:03:56', NULL, '917515bd4833dddf3299994c1d6739d2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 471f1077..5503321b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +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. - 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, diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 9e3f998a..00553d1e 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -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 { static String? _tmuxConfPath; StreamSubscription? _eventSub; + StreamSubscription? _statusSub; ConversationController? _conversation; TranscriptPublisher? _feed; + SessionStatus _status = const SessionStatus(); String? _paneId; String? _sessionName; String? _sessionId; @@ -71,6 +74,8 @@ class _ClaudePaneState extends State { _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 { 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 { } else if (_conversation != null) { body = Column( children: [ + if (!_status.isEmpty) ClaudeStatusStrip(status: _status), Expanded( child: ConversationView( controller: _conversation!, diff --git a/lib/builtin/claude/src/claude_status_strip.dart b/lib/builtin/claude/src/claude_status_strip.dart new file mode 100644 index 00000000..c5e3081b --- /dev/null +++ b/lib/builtin/claude/src/claude_status_strip.dart @@ -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'; +} diff --git a/lib/builtin/claude/src/transcript_publisher.dart b/lib/builtin/claude/src/transcript_publisher.dart index 4a1c1a2c..b8e782bd 100644 --- a/lib/builtin/claude/src/transcript_publisher.dart +++ b/lib/builtin/claude/src/transcript_publisher.dart @@ -56,6 +56,10 @@ class TranscriptPublisher { final String channel; late final StreamSubscription _sub; + /// Live session status (model / permission-mode / context) from the + /// underlying reader — passed through for the status strip (T-145). + Stream get statusStream => _reader.statusStream; + /// Stops publishing and tears down the underlying reader. Future dispose() async { await _sub.cancel(); diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index 6066389f..dae2a6c1 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -218,6 +218,8 @@ class TranscriptReader { } StreamController? _controller; + final StreamController _statusController = StreamController.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 get statusStream { + _controller ??= _start(); + return _statusController.stream; + } + /// Cancels polling and closes the underlying stream. Future 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 items, List 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 items, List 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 items, List warnings}); ParsedChunk parseTranscriptChunk(String chunk) { final items = []; final warnings = []; + 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 out, List warnings) { +void _parseLineInto(String line, List out, List warnings, _StatusAcc status) { Map envelope; try { envelope = (jsonDecode(line) as Map).cast(); @@ -402,7 +464,16 @@ void _parseLineInto(String line, List out, List 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 out, List 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 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 envelope, String uuid, diff --git a/test/builtin/claude/claude_status_strip_test.dart b/test/builtin/claude/claude_status_strip_test.dart new file mode 100644 index 00000000..4cb0e20d --- /dev/null +++ b/test/builtin/claude/claude_status_strip_test.dart @@ -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); + }); + }); +} diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index 85c123d0..0b8c9c45 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -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(), 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().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 = []; + 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); + }); }); }