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:
@@ -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;
|
||||
|
||||
@@ -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 (<munged-cwd>/<session-id>.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-<slug> 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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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