diff --git a/lib/chat/panels/context_usage_panel.dart b/lib/chat/panels/context_usage_panel.dart index 1491066ff..330f25db8 100644 --- a/lib/chat/panels/context_usage_panel.dart +++ b/lib/chat/panels/context_usage_panel.dart @@ -46,8 +46,7 @@ class ContextUsagePanel extends StatefulWidget { /// Claude Code's categories (by their English names, as it reports /// them) as the usage views' parts: an agent's definition is in the /// Agent tool's, the servers' instructions with their tools, the slash - /// commands with the skills. - @visibleForTesting + /// commands with the skills. The phone is told them so too. static String? partOf(String category) => switch (category.toLowerCase().trim()) { 'system prompt' => 'system', diff --git a/lib/link/workspace_bridge.dart b/lib/link/workspace_bridge.dart index 6448f1ef0..4990168ec 100644 --- a/lib/link/workspace_bridge.dart +++ b/lib/link/workspace_bridge.dart @@ -12,6 +12,7 @@ import '../bots/bots.dart'; import '../chat/chat_models.dart'; import '../chat/chat_session.dart'; import '../chat/composer/composer_files.dart' show fileReferenceText; +import '../chat/panels/context_usage_panel.dart' show ContextUsagePanel; import '../chat/rewind_files_setting.dart'; import '../customize/customization_store.dart'; import '../customize/customizations.dart'; @@ -1287,9 +1288,11 @@ class _Follow { ? null : _from > 0 || session.hasEarlierHistory || session.loadingEarlier; final goal = _goal(session.goal); + final context = _context(session.context); final restText = jsonEncode([ earlier, goal, + context, session.isStreaming, interaction, [for (final choice in choices) choice.toJson()], @@ -1328,9 +1331,31 @@ class _Follow { tasks: tasks, earlier: earlier, goal: goal, + context: context, ); } + /// [usage] as the phone reads it (see [LinkSessionUpdate.context]). + static Map? _context(ContextUsage? usage) { + if (usage == null || usage.window <= 0) return null; + final parts = {}; + for (final segment in usage.segments) { + if (segment.kind != ContextKind.used || segment.tokens <= 0) continue; + final part = ContextUsagePanel.partOf(segment.label) ?? segment.label; + parts[part] = (parts[part] ?? 0) + segment.tokens; + } + return { + 'window': usage.window, + 'used': usage.used, + if (parts.isNotEmpty) + 'parts': [ + for (final MapEntry(:key, :value) in parts.entries) + {'part': key, 'tokens': value}, + ], + 'compactAt': ?usage.detail?.autoCompactAt, + }; + } + /// [goal] as the phone reads it (see [LinkSessionUpdate.goal]). static Map? _goal(KernelGoal? goal) => switch (goal) { null => null, diff --git a/packages/bao_link/lib/src/protocol.dart b/packages/bao_link/lib/src/protocol.dart index a9728d499..a02cdb6f2 100644 --- a/packages/bao_link/lib/src/protocol.dart +++ b/packages/bao_link/lib/src/protocol.dart @@ -749,6 +749,7 @@ class LinkSessionUpdate { this.tasks = const [], this.earlier, this.goal, + this.context, }); factory LinkSessionUpdate.fromJson(Map json) => @@ -779,6 +780,10 @@ class LinkSessionUpdate { final Map map => map, _ => null, }, + context: switch (json['context']) { + final Map map => map, + _ => null, + }, ); final String session; @@ -814,6 +819,13 @@ class LinkSessionUpdate { /// "duration", "tokens"}`, its times in milliseconds. final Map? goal; + /// How full the agent's context is, if its kernel says: `{"window", + /// "used", "parts": [{"part", "tokens"}], "compactAt"}`, a part by the + /// desktop's key for it (`system`, `tools`, `mcp`, `memory`, `skills`, + /// `messages`) or the kernel's own name, `compactAt` where it is + /// compacted, in tokens. + final Map? context; + Map toJson() => { 'session': session, 'start': start, @@ -826,6 +838,7 @@ class LinkSessionUpdate { if (tasks.isNotEmpty) 'tasks': [for (final task in tasks) task.toJson()], 'earlier': ?earlier, 'goal': ?goal, + 'context': ?context, }; } diff --git a/packages/bao_link/test/link_test.dart b/packages/bao_link/test/link_test.dart index b848c115d..35857e16c 100644 --- a/packages/bao_link/test/link_test.dart +++ b/packages/bao_link/test/link_test.dart @@ -55,6 +55,32 @@ void main() { } }); + test('a session update carries how full the context is, when told', () { + const plain = LinkSessionUpdate( + session: 's', + start: 0, + items: [], + count: 0, + ); + expect(plain.toJson().containsKey('context'), isFalse); + const context = { + 'window': 200000, + 'used': 52000, + 'parts': [ + {'part': 'messages', 'tokens': 40000}, + ], + 'compactAt': 167000, + }; + const update = LinkSessionUpdate( + session: 's', + start: 0, + items: [], + count: 0, + context: context, + ); + expect(LinkSessionUpdate.fromJson(update.toJson()).context, context); + }); + test('a bot\'s picture is told from an emoji by its file\'s name', () { expect(isAvatarImage('avatar-0a1b2c3d4e5f.jpg'), isTrue); expect(isAvatarImage('avatar-0a1b2c3d4e5f.PNG'), isFalse);