Activity tab: session controls + live usage block (T-415)

The Activity tab gains the power-panel's session strip and a usage block:

- SESSION controls (clear / compact / fork / resume + refresh-usage)
  publish their slash command on builtin.claude/command — the same path
  as typing it, so /clear semantics (and any future confirm behavior)
  live in exactly one place.
- The usage block revisits T-158's "blocked on upstream": probed against
  claude 2.1.175, a forwarded /usage IS answered headless, free
  (num_turns 0), as parseable text. parseUsageText() extracts session /
  week / week-Sonnet percentages (timezone parentheticals stripped); the
  sidebar watches the primary session's synthetic output for
  usage-shaped responses and renders them as a USAGE section. Refresh is
  user-initiated (the control sends /usage) — no polling, no background
  calls (D-64).
- The runtime row gains the session's effort level (T-412's status
  field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:04:55 +02:00
co-authored by Claude Fable 5
parent 1674d3021a
commit 0fd8a241db
9 changed files with 183 additions and 13 deletions
@@ -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;
+4
View File
@@ -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);
+5
View File
@@ -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
@@ -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<ClaudeMetaSidebar> {
StreamSubscription<Message>? _statusSub;
StreamSubscription<Message>? _tabSub;
StreamSubscription<SessionStatus>? _primarySub;
StreamSubscription<ConversationItem>? _primaryItemsSub;
ClaudeUsage? _usage;
StreamSubscription<void>? _brokerChangeSub;
Timer? _timer;
late final Future<ClaudeStats> Function() _load;
@@ -199,6 +202,8 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
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<ClaudeMetaSidebar> {
_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<ClaudeMetaSidebar> {
_statusSub?.cancel();
_tabSub?.cancel();
_primarySub?.cancel();
_primaryItemsSub?.cancel();
_brokerChangeSub?.cancel();
_injectCtl.dispose();
_config?.removeListener(_onConfigChange);
@@ -272,7 +286,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
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,
+38
View File
@@ -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;
}
@@ -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 = <MetaSection>[
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 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),
],
);
}
return buildMetaTable(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<MetaSection> _runtimeSection(SurfaceTokens tokens) {
@@ -43,6 +99,7 @@ class ActivityTabView extends StatelessWidget {
final skills = config?.skills.length;
final rows = <MetaRow>[
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'),
@@ -44,7 +44,12 @@ Widget metaPlaceholder(String text) => Padding(
);
/// Key→value sections on the shared table geometry (Activity + Config).
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> 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<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
final children = <Widget>[];
for (var i = 0; i < sections.length; i++) {
final s = sections[i];
@@ -74,5 +79,5 @@ Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
);
}
}
return ListView(padding: const EdgeInsets.all(12), children: children);
return children;
}
@@ -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 = <Message>[];
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));
@@ -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);
});
});
}