diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index ceb6a664..ccea20a6 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4353,3 +4353,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'status', 'backlog', 'ready', NULL, '2026-06-12 11:28:28', '2026-06-12 11:28:28', '2026-06-12 11:28:28', NULL, 'ccb4c4193c34ef5e2bd9e20d407ccda3', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'backlog', 'ready', NULL, '2026-06-12 11:28:32', '2026-06-12 11:28:32', '2026-06-12 11:28:32', NULL, '93622dd3b0a88598d1823685a0c2d3e6', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'status', 'backlog', 'ready', NULL, '2026-06-12 11:28:38', '2026-06-12 11:28:38', '2026-06-12 11:28:38', NULL, '5729470fe53b18812c0338fa4f23a3c8', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'status', 'ready', 'done', NULL, '2026-06-12 11:30:22', '2026-06-12 11:30:22', '2026-06-12 11:30:22', NULL, '6dd59c198c66791e49bad07edcc24882', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'status', 'ready', 'in_progress', NULL, '2026-06-12 11:30:55', '2026-06-12 11:30:55', '2026-06-12 11:30:55', NULL, 'c5a086c9415df9fbe4e7feacecc19c73', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 65a10768..e7f84750 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4777,3 +4777,7 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 11:28:32', NULL, '07318aee4082e6af072c15605745a41d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'task', '06FBP3EZC7AJANXZVF3D91QYWM', 'Claude sidebar tab robot icon in claude orange', 'User request (2026-06-12, try-it-out): tint the Claude sidebar tab''s robot icon with the Claude accent (#D97757, claudeAccent) so the Claude surface is identifiable at a glance in the icon rail. Nominative use per the licenses.yaml trademark note (marks Claude''s own panel, not clide branding). If the TabContribution/icon-rail doesn''t support per-tab colour, add an optional iconColor to the contribution and honour it in the rail (active/inactive states: keep the accent when active, mute toward the normal inactive tone when not).', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 11:26:14', '2026-06-12 11:28:38', NULL, 'bffe2b9dfafc4a13412ceb5c05700915', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'task', '06FBP3EZC7AJANXZVF3D91QYWM', 'Claude sidebar tab robot icon in claude orange', 'User request (2026-06-12, try-it-out): tint the Claude sidebar tab''s robot icon with the Claude accent (#D97757, claudeAccent) so the Claude surface is identifiable at a glance in the icon rail. Nominative use per the licenses.yaml trademark note (marks Claude''s own panel, not clide branding). If the TabContribution/icon-rail doesn''t support per-tab colour, add an optional iconColor to the contribution and honour it in the rail (active/inactive states: keep the accent when active, mute toward the normal inactive tone when not).', 'done', 'low', NULL, NULL, NULL, '2026-06-12 11:26:14', '2026-06-12 11:30:22', NULL, '2f7fd997294bca6437808ba784624c47', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Activity tab: session controls + usage block (revisit T-158)', 'Two additions to the sidebar Activity tab. (1) Session controls row: clear / compact / fork / resume as icon buttons driving the exact same handlers as the slash commands (claude_pane._send routes) — D-6 parity for the session-mutating set. Confirm-on-destructive (/clear) via the existing dialog pattern. + +(2) Usage/cost block: the version-keyed probes show /usage IS advertised in stream-json (init-2.1.16x.json) — probe what forwarded /usage returns in headless mode and, if parseable, render plan usage alongside the existing cost/context data from result events (T-168 fields already in SessionStatus). This revisits T-158 ''blocked on upstream'' — if the output is unusable, document that in T-158 and keep the cost/context-only block. Link T-158 either way.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 11:30:55', NULL, 'ea7652bcef396769e394a0d436920d8e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 480de7d8..78e30e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Session controls and live usage in the Claude sidebar Activity tab.** A + SESSION strip offers clear/compact/fork/resume buttons (same code path as the + typed commands), and a refresh control fetches `/usage` — plan usage renders + as a USAGE block (session and weekly percentages). The runtime row now also + shows the session's effort level. (T-415) - **The Claude sidebar Config tab is a live control panel.** Model, effort, and permission mode are popover controls showing the running session's values; picking an option drives the session through the same path as the typed slash diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index 89ab3f8b..d2bec2bc 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -36,8 +36,9 @@ import 'package:clide/builtin/claude/src/meta_sidebar/tab_strip.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/team_tab.dart'; import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask; +import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, parseUsageText; import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation; -import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; +import 'package:clide/builtin/claude/src/transcript_reader.dart' show AssistantTextMessage, ConversationItem, SessionStatus; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/widgets.dart'; @@ -85,6 +86,8 @@ class _ClaudeMetaSidebarState extends State { StreamSubscription? _statusSub; StreamSubscription? _tabSub; StreamSubscription? _primarySub; + StreamSubscription? _primaryItemsSub; + ClaudeUsage? _usage; StreamSubscription? _brokerChangeSub; Timer? _timer; late final Future Function() _load; @@ -199,6 +202,8 @@ class _ClaudeMetaSidebarState extends State { final session = _orchestrator?.byId('primary')?.session; _primarySub?.cancel(); _primarySub = null; + _primaryItemsSub?.cancel(); + _primaryItemsSub = null; if (session == null) { if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null); return; @@ -208,6 +213,14 @@ class _ClaudeMetaSidebarState extends State { _primarySub = session.statusStream.listen((s) { if (mounted) setState(() => _primaryStatus = s); }); + // Watch for /usage responses: CLI-local output arrives as synthetic + // assistant text; when it parses as usage, the Activity block updates + // (T-415). Driven by the refresh control publishing '/usage'. + _primaryItemsSub = session.items.listen((item) { + if (item is! AssistantTextMessage || !item.synthetic) return; + final parsed = parseUsageText(item.text); + if (parsed != null && mounted) setState(() => _usage = parsed); + }); } void _onConfigChange() { @@ -257,6 +270,7 @@ class _ClaudeMetaSidebarState extends State { _statusSub?.cancel(); _tabSub?.cancel(); _primarySub?.cancel(); + _primaryItemsSub?.cancel(); _brokerChangeSub?.cancel(); _injectCtl.dispose(); _config?.removeListener(_onConfigChange); @@ -272,7 +286,7 @@ class _ClaudeMetaSidebarState extends State { SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)), Expanded( child: switch (_tab) { - SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config), + SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage), SidebarTab.team => TeamTabView( members: _members, memberStatus: _memberStatus, diff --git a/lib/builtin/claude/src/claude_status.dart b/lib/builtin/claude/src/claude_status.dart index 3ee0cea6..2720da40 100644 --- a/lib/builtin/claude/src/claude_status.dart +++ b/lib/builtin/claude/src/claude_status.dart @@ -92,3 +92,41 @@ String formatTokenCount(int n) { if (n >= 1000) return '${(n / 1000).round()}k'; return '$n'; } + +/// Parsed `/usage` output (T-415). The CLI answers a forwarded `/usage` +/// headless and free (probed 2.1.175, num_turns 0) with plain text: +/// +/// Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam) +/// Current week (all models): 53% used · resets Jun 15, 6:59pm (…) +/// Current week (Sonnet only): 0% used +class ClaudeUsage { + const ClaudeUsage({this.session, this.week, this.weekSonnet}); + + /// The value text per line (e.g. `15% used · resets Jun 12, 3:39pm`), + /// timezone parenthetical stripped. Null when the line wasn't present. + final String? session; + final String? week; + final String? weekSonnet; + + bool get isEmpty => session == null && week == null && weekSonnet == null; +} + +/// Parse `/usage` response text into a [ClaudeUsage], or null when [text] +/// isn't usage output. Tolerant of label drift: any `Current …: …% used` +/// line is matched by its key phrase. +ClaudeUsage? parseUsageText(String text) { + if (!text.contains('% used')) return null; + String? valueOf(String keyPhrase) { + for (final line in text.split('\n')) { + if (!line.contains(keyPhrase)) continue; + final colon = line.indexOf(':'); + if (colon < 0) continue; + // Strip the trailing timezone parenthetical — noise at sidebar width. + return line.substring(colon + 1).replaceAll(RegExp(r'\s*\([^)]*\)\s*$'), '').trim(); + } + return null; + } + + final usage = ClaudeUsage(session: valueOf('Current session'), week: valueOf('(all models)'), weekSonnet: valueOf('(Sonnet only)')); + return usage.isEmpty ? null : usage; +} diff --git a/lib/builtin/claude/src/meta_sidebar/activity_tab.dart b/lib/builtin/claude/src/meta_sidebar/activity_tab.dart index c55fbb73..8e5950f3 100644 --- a/lib/builtin/claude/src/meta_sidebar/activity_tab.dart +++ b/lib/builtin/claude/src/meta_sidebar/activity_tab.dart @@ -1,28 +1,47 @@ -/// The Activity tab: usage stats (stats-cache.json) + the primary -/// session's live runtime row. Split out of claude_meta_sidebar.dart -/// (T-395). +/// The Activity tab: session controls, usage, stats (stats-cache.json), and +/// the primary session's live runtime row. Split out of +/// claude_meta_sidebar.dart (T-395); session controls + the usage block are +/// the power-panel additions (T-415). library; import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_stats.dart'; -import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel; +import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel; import 'package:clide/builtin/claude/src/meta_sidebar/models.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; class ActivityTabView extends StatelessWidget { - const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config}); + const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config, this.usage}); final ClaudeStats stats; final SessionStatus? primaryStatus; final ClaudeConfig? config; + /// Parsed `/usage` output for the usage block, refreshed via the refresh + /// control (T-415). Null until the first refresh. + final ClaudeUsage? usage; + + /// Publish a slash command for the primary pane to execute — the session + /// controls are the same code path as typing the command (D-6). + void _command(BuildContext context, String text) { + ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': text}); + } + @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; final latest = stats.latest; + final u = usage; final sections = [ + if (u != null) + MetaSection('USAGE', [ + if (u.session != null) MetaRow('session', u.session!), + if (u.week != null) MetaRow('week (all)', u.week!), + if (u.weekSonnet != null) MetaRow('week (sonnet)', u.weekSonnet!), + ]), if (latest != null) MetaSection('TODAY', [ MetaRow('messages', '${latest.messageCount}'), @@ -32,10 +51,47 @@ class ActivityTabView extends StatelessWidget { if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]), ..._runtimeSection(tokens), ]; - if (sections.isEmpty) { - return metaPlaceholder('No activity recorded yet.'); - } - return buildMetaTable(tokens, sections); + + return ListView( + padding: const EdgeInsets.all(12), + children: [ + // SESSION control strip (T-415): drives the primary session through + // the builtin.claude/command bus — identical to typing the command. + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClideText('SESSION', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader), + ), + Row( + children: [ + _control(context, tokens, 'clear', 'trash', '/clear'), + _control(context, tokens, 'compact', 'arrows-in-simple', '/compact'), + _control(context, tokens, 'fork', 'git-branch', '/fork'), + _control(context, tokens, 'resume', 'clock-counter-clockwise', '/resume'), + const Spacer(), + _control(context, tokens, 'refresh usage', 'arrow-clockwise', '/usage'), + ], + ), + const SizedBox(height: 6), + if (sections.isEmpty) metaPlaceholder('No activity recorded yet.') else ...metaTableChildren(tokens, sections), + ], + ); + } + + Widget _control(BuildContext context, SurfaceTokens tokens, String label, String glyph, String command) { + return Semantics( + button: true, + label: '$label session', + excludeSemantics: true, + onTap: () => _command(context, command), + child: ClideTappable( + tooltip: '$label · $command', + onTap: () => _command(context, command), + builder: (ctx, hovered, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: ClideIcon(PhosphorIcons.byName(glyph), size: 15, color: hovered ? tokens.globalForeground : tokens.globalTextMuted), + ), + ), + ); } List _runtimeSection(SurfaceTokens tokens) { @@ -43,6 +99,7 @@ class ActivityTabView extends StatelessWidget { final skills = config?.skills.length; final rows = [ if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus), + if (st?.effort != null) MetaRow('effort', st!.effort!), if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'), if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)), if (skills != null) MetaRow('skills', '$skills'), diff --git a/lib/builtin/claude/src/meta_sidebar/models.dart b/lib/builtin/claude/src/meta_sidebar/models.dart index 59345c9c..f931985e 100644 --- a/lib/builtin/claude/src/meta_sidebar/models.dart +++ b/lib/builtin/claude/src/meta_sidebar/models.dart @@ -44,7 +44,12 @@ Widget metaPlaceholder(String text) => Padding( ); /// Key→value sections on the shared table geometry (Activity + Config). -Widget buildMetaTable(SurfaceTokens tokens, List sections) { +Widget buildMetaTable(SurfaceTokens tokens, List sections) => + ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections)); + +/// The table rows without the enclosing ListView, for tabs that compose extra +/// widgets around the sections (the Activity tab's control strip, T-415). +List metaTableChildren(SurfaceTokens tokens, List sections) { final children = []; for (var i = 0; i < sections.length; i++) { final s = sections[i]; @@ -74,5 +79,5 @@ Widget buildMetaTable(SurfaceTokens tokens, List sections) { ); } } - return ListView(padding: const EdgeInsets.all(12), children: children); + return children; } diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index 90ff5f21..a8962e38 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -133,6 +133,22 @@ void main() { expect(find.text('Claude environment not loaded.'), findsOneWidget); }); + testWidgets('Activity session controls publish their slash commands (T-415)', (tester) async { + final published = []; + final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen(published.add); + addTearDown(sub.cancel); + + await tester.pumpWidget(harness(f, sidebar(stats: stats))); + await tester.pumpAndSettle(); + expect(find.text('SESSION'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('clear session')); + await tester.tap(find.bySemanticsLabel('compact session')); + await tester.tap(find.bySemanticsLabel('refresh usage session')); + await tester.pump(); + expect(published.map((m) => m.data['text']), ['/clear', '/compact', '/usage']); + }); + testWidgets('a settings control publishes its slash command on pick (T-414)', (tester) async { final dir = Directory.systemTemp.createTempSync('cfg'); addTearDown(() => dir.deleteSync(recursive: true)); diff --git a/test/builtin/claude/claude_status_test.dart b/test/builtin/claude/claude_status_test.dart index 04526168..d7b8237e 100644 --- a/test/builtin/claude/claude_status_test.dart +++ b/test/builtin/claude/claude_status_test.dart @@ -106,4 +106,33 @@ void main() { expect(seg.trailing, isNull); }); }); + + group('parseUsageText (T-415)', () { + // The probed 2.1.175 /usage response shape. + const probed = + 'You are currently using your subscription to power your Claude Code usage\n' + '\n' + 'Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam)\n' + 'Current week (all models): 53% used · resets Jun 15, 6:59pm (Europe/Amsterdam)\n' + 'Current week (Sonnet only): 0% used'; + + test('parses the probed response, stripping timezone parentheticals', () { + final u = parseUsageText(probed)!; + expect(u.session, '15% used · resets Jun 12, 3:39pm'); + expect(u.week, '53% used · resets Jun 15, 6:59pm'); + expect(u.weekSonnet, '0% used'); + }); + + test('tolerates missing lines', () { + final u = parseUsageText('Current session: 9% used')!; + expect(u.session, '9% used'); + expect(u.week, isNull); + expect(u.weekSonnet, isNull); + }); + + test('non-usage text parses to null', () { + expect(parseUsageText("/effort isn't available in this environment."), isNull); + expect(parseUsageText('plain prose'), isNull); + }); + }); }