From e54bc9be29528bb75c72f0a6b6b7b62ab5736c7f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 18:26:55 +0000 Subject: [PATCH] feat: add Ctrl+O shortcut to toggle thinking token display Add keyboard shortcut (Ctrl+O) to toggle visibility of thinking/reasoning tokens from Claude's extended thinking feature. Changes: - Add ThinkingResponse class to parse thinking content blocks from API - Update conversation loader and response processor to handle thinking - Add showThinking setting persisted in ~/.vide/settings.json - Add showThinkingProvider for state management - Add Ctrl+O handler with visual feedback message - Conditionally render thinking content in message display --- .../agent_network/network_execution_page.dart | 70 ++++++++++++++++++- lib/theme/theme_provider.dart | 16 +++++ .../lib/src/client/conversation_loader.dart | 15 +++- .../lib/src/client/response_processor.dart | 5 +- .../claude_sdk/lib/src/models/response.dart | 24 +++++++ .../claude_sdk/lib/src/models/response.g.dart | 16 +++++ .../lib/models/vide_global_settings.dart | 8 +++ .../lib/models/vide_global_settings.g.dart | 2 + .../lib/services/vide_config_manager.dart | 11 +++ 9 files changed, 163 insertions(+), 4 deletions(-) diff --git a/lib/modules/agent_network/network_execution_page.dart b/lib/modules/agent_network/network_execution_page.dart index 7fa766b4..b449e1e0 100644 --- a/lib/modules/agent_network/network_execution_page.dart +++ b/lib/modules/agent_network/network_execution_page.dart @@ -18,6 +18,7 @@ import 'package:vide_cli/modules/commands/command_provider.dart'; import 'package:vide_core/vide_core.dart'; import 'package:vide_cli/modules/permissions/permission_service.dart'; import 'package:vide_cli/theme/theme.dart'; +import 'package:vide_cli/theme/theme_provider.dart'; import '../permissions/permission_scope.dart'; import '../../components/typing_text.dart'; @@ -42,6 +43,7 @@ class NetworkExecutionPage extends StatefulComponent { class _NetworkExecutionPageState extends State { DateTime? _lastCtrlCPress; bool _showQuitWarning = false; + String? _thinkingToggleMessage; static const _quitTimeWindow = Duration(seconds: 2); int selectedAgentIndex = 0; @@ -119,6 +121,24 @@ class _NetworkExecutionPageState extends State { } } + void _handleCtrlO() { + // Toggle thinking tokens display + toggleShowThinking(context.container); + final newValue = context.read(showThinkingProvider); + setState(() { + _thinkingToggleMessage = newValue ? 'Thinking tokens: visible' : 'Thinking tokens: hidden'; + }); + + // Hide message after 2 seconds + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _thinkingToggleMessage = null; + }); + } + }); + } + @override Component build(BuildContext context) { final networkState = context.watch(agentNetworkManagerProvider); @@ -145,6 +165,12 @@ class _NetworkExecutionPageState extends State { return true; } + // Ctrl+O: Toggle thinking tokens display + if (event.logicalKey == LogicalKey.keyO && event.isControlPressed) { + _handleCtrlO(); + return true; + } + return false; }, child: MouseRegion( @@ -160,6 +186,15 @@ class _NetworkExecutionPageState extends State { ), Divider(), RunningAgentsBar(agents: networkState.agents, selectedIndex: selectedAgentIndex), + // Show thinking toggle message if active + if (_thinkingToggleMessage != null) + Container( + padding: EdgeInsets.symmetric(horizontal: 1), + child: Text( + _thinkingToggleMessage!, + style: TextStyle(color: VideTheme.of(context).base.primary), + ), + ), if (networkState.agentIds.isEmpty) Center(child: Text('No agents')) else @@ -580,8 +615,41 @@ class _AgentChatState extends State<_AgentChat> { final widgets = []; final renderedToolResults = {}; + // Check if we should show thinking content + final showThinking = context.watch(showThinkingProvider); + for (final response in message.responses) { - if (response is TextResponse) { + if (response is ThinkingResponse) { + // Only render thinking content if showThinking is enabled + if (showThinking && response.content.isNotEmpty) { + widgets.add( + Container( + padding: EdgeInsets.only(bottom: 1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Thinking:', + style: TextStyle( + color: theme.base.onSurface.withOpacity(TextOpacity.secondary), + fontStyle: FontStyle.italic, + ), + ), + Container( + padding: EdgeInsets.only(left: 2), + child: Text( + response.content, + style: TextStyle( + color: theme.base.onSurface.withOpacity(TextOpacity.secondary), + ), + ), + ), + ], + ), + ), + ); + } + } else if (response is TextResponse) { if (response.content.isEmpty && message.isStreaming) { widgets.add(EnhancedLoadingIndicator()); } else { diff --git a/lib/theme/theme_provider.dart b/lib/theme/theme_provider.dart index 11309dac..9cccbe63 100644 --- a/lib/theme/theme_provider.dart +++ b/lib/theme/theme_provider.dart @@ -23,3 +23,19 @@ void setTheme(ProviderContainer container, String? themeId) { configManager.setTheme(themeId); container.read(themeSettingProvider.notifier).state = themeId; } + +/// Provider for controlling whether thinking tokens are displayed. +/// Toggle with Ctrl+O shortcut. +final showThinkingProvider = StateProvider((ref) { + final configManager = ref.read(videConfigManagerProvider); + return configManager.getShowThinking(); +}); + +/// Toggles the showThinking setting and persists it to config. +void toggleShowThinking(ProviderContainer container) { + final configManager = container.read(videConfigManagerProvider); + final current = container.read(showThinkingProvider); + final newValue = !current; + configManager.setShowThinking(newValue); + container.read(showThinkingProvider.notifier).state = newValue; +} diff --git a/packages/claude_sdk/lib/src/client/conversation_loader.dart b/packages/claude_sdk/lib/src/client/conversation_loader.dart index 06d7c407..44e4adcf 100644 --- a/packages/claude_sdk/lib/src/client/conversation_loader.dart +++ b/packages/claude_sdk/lib/src/client/conversation_loader.dart @@ -290,7 +290,20 @@ class ConversationLoader { if (block is Map) { final blockType = block['type'] as String?; - if (blockType == 'text') { + if (blockType == 'thinking') { + final thinking = block['thinking'] as String? ?? ''; + if (thinking.isNotEmpty) { + responses.add( + ThinkingResponse( + id: + block['id'] as String? ?? + DateTime.now().millisecondsSinceEpoch.toString(), + timestamp: DateTime.now(), + content: HtmlEntityDecoder.decode(thinking), + ), + ); + } + } else if (blockType == 'text') { final text = block['text'] as String? ?? ''; if (text.isNotEmpty) { responses.add( diff --git a/packages/claude_sdk/lib/src/client/response_processor.dart b/packages/claude_sdk/lib/src/client/response_processor.dart index 966e8e48..1aaff6fb 100644 --- a/packages/claude_sdk/lib/src/client/response_processor.dart +++ b/packages/claude_sdk/lib/src/client/response_processor.dart @@ -46,7 +46,7 @@ class ResponseProcessor { responses = [response]; } - if (response is TextResponse) { + if (response is TextResponse || response is ThinkingResponse) { return _processTextResponse( response, currentConversation, @@ -92,13 +92,14 @@ class ResponseProcessor { } ProcessResult _processTextResponse( - TextResponse response, + ClaudeResponse response, Conversation currentConversation, String assistantId, ConversationMessage? existingMessage, bool isAssistantMessage, List responses, ) { + // Works for both TextResponse and ThinkingResponse // Extract usage if available final usage = _extractUsageFromRawData(response.rawData); diff --git a/packages/claude_sdk/lib/src/models/response.dart b/packages/claude_sdk/lib/src/models/response.dart index 995df468..8733004e 100644 --- a/packages/claude_sdk/lib/src/models/response.dart +++ b/packages/claude_sdk/lib/src/models/response.dart @@ -75,6 +75,30 @@ sealed class ClaudeResponse { } } +/// Response containing thinking/reasoning content from extended thinking. +@JsonSerializable() +class ThinkingResponse extends ClaudeResponse { + final String content; + + const ThinkingResponse({ + required super.id, + required super.timestamp, + required this.content, + super.rawData, + }); + + factory ThinkingResponse.fromJson(Map json) { + return ThinkingResponse( + id: json['id'] ?? DateTime.now().millisecondsSinceEpoch.toString(), + timestamp: DateTime.now(), + content: json['thinking'] ?? json['content'] ?? json['text'] ?? '', + rawData: json, + ); + } + + Map toJson() => _$ThinkingResponseToJson(this); +} + @JsonSerializable() class TextResponse extends ClaudeResponse { final String content; diff --git a/packages/claude_sdk/lib/src/models/response.g.dart b/packages/claude_sdk/lib/src/models/response.g.dart index 4140d7cd..cdecc794 100644 --- a/packages/claude_sdk/lib/src/models/response.g.dart +++ b/packages/claude_sdk/lib/src/models/response.g.dart @@ -6,6 +6,22 @@ part of 'response.dart'; // JsonSerializableGenerator // ************************************************************************** +ThinkingResponse _$ThinkingResponseFromJson(Map json) => + ThinkingResponse( + id: json['id'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + content: json['content'] as String, + rawData: json['rawData'] as Map?, + ); + +Map _$ThinkingResponseToJson(ThinkingResponse instance) => + { + 'id': instance.id, + 'timestamp': instance.timestamp.toIso8601String(), + 'rawData': instance.rawData, + 'content': instance.content, + }; + TextResponse _$TextResponseFromJson(Map json) => TextResponse( id: json['id'] as String, timestamp: DateTime.parse(json['timestamp'] as String), diff --git a/packages/vide_core/lib/models/vide_global_settings.dart b/packages/vide_core/lib/models/vide_global_settings.dart index 1dfc18ba..7d53de51 100644 --- a/packages/vide_core/lib/models/vide_global_settings.dart +++ b/packages/vide_core/lib/models/vide_global_settings.dart @@ -17,9 +17,15 @@ class VideGlobalSettings { @JsonKey(includeIfNull: false) final String? theme; + /// Whether to show thinking/reasoning tokens in the UI. + /// Defaults to false (hidden). + @JsonKey(defaultValue: false) + final bool showThinking; + const VideGlobalSettings({ this.firstRunComplete = false, this.theme, + this.showThinking = false, }); factory VideGlobalSettings.defaults() => const VideGlobalSettings(); @@ -32,10 +38,12 @@ class VideGlobalSettings { VideGlobalSettings copyWith({ bool? firstRunComplete, String? Function()? theme, + bool? showThinking, }) { return VideGlobalSettings( firstRunComplete: firstRunComplete ?? this.firstRunComplete, theme: theme != null ? theme() : this.theme, + showThinking: showThinking ?? this.showThinking, ); } } diff --git a/packages/vide_core/lib/models/vide_global_settings.g.dart b/packages/vide_core/lib/models/vide_global_settings.g.dart index 105add42..b4cfdcaa 100644 --- a/packages/vide_core/lib/models/vide_global_settings.g.dart +++ b/packages/vide_core/lib/models/vide_global_settings.g.dart @@ -10,10 +10,12 @@ VideGlobalSettings _$VideGlobalSettingsFromJson(Map json) => VideGlobalSettings( firstRunComplete: json['firstRunComplete'] as bool? ?? false, theme: json['theme'] as String?, + showThinking: json['showThinking'] as bool? ?? false, ); Map _$VideGlobalSettingsToJson(VideGlobalSettings instance) => { 'firstRunComplete': instance.firstRunComplete, if (instance.theme case final value?) 'theme': value, + 'showThinking': instance.showThinking, }; diff --git a/packages/vide_core/lib/services/vide_config_manager.dart b/packages/vide_core/lib/services/vide_config_manager.dart index 4017488c..596de392 100644 --- a/packages/vide_core/lib/services/vide_config_manager.dart +++ b/packages/vide_core/lib/services/vide_config_manager.dart @@ -149,6 +149,17 @@ class VideConfigManager { final settings = readGlobalSettings(); writeGlobalSettings(settings.copyWith(theme: () => themeName)); } + + /// Get whether to show thinking tokens in the UI. + bool getShowThinking() { + return readGlobalSettings().showThinking; + } + + /// Set whether to show thinking tokens in the UI. + void setShowThinking(bool show) { + final settings = readGlobalSettings(); + writeGlobalSettings(settings.copyWith(showThinking: show)); + } } /// Riverpod provider for VideConfigManager