diff --git a/lib/main.dart b/lib/main.dart index c334a1a0..af4ec38c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:vide_cli/theme/theme.dart'; import 'package:vide_core/vide_core.dart'; import 'package:vide_cli/modules/agent_network/state/agent_networks_state_notifier.dart'; import 'package:vide_cli/services/sentry_service.dart'; +import 'package:vide_cli/services/vide_settings.dart'; /// Provider for sidebar focus state, shared across the app. /// Pages can update this to give focus to the sidebar. @@ -69,6 +70,9 @@ void main(List args, {List overrides = const []}) async { // before launching the actual binary. The version indicator shows "ready" when // an update has been downloaded and will be applied on next launch. + // Load app settings + await VideSettingsManager.instance.load(); + await container.read(agentNetworksStateNotifierProvider.notifier).init(); await runApp( diff --git a/lib/modules/agent_network/network_execution_page.dart b/lib/modules/agent_network/network_execution_page.dart index b48f6f7d..75ed844c 100644 --- a/lib/modules/agent_network/network_execution_page.dart +++ b/lib/modules/agent_network/network_execution_page.dart @@ -312,9 +312,20 @@ class _AgentChatState extends State<_AgentChat> { } void _sendMessage(Message message) { + // Clear any previous sommelier commentary + context.read(codeSommelierProvider.notifier).state = null; + // Generate creative loading words with Haiku in the background _generateLoadingWords(message.text); + // Check for code and trigger sommelier if enabled (delayed to avoid race with loading words) + final textToCheck = message.text; + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + _generateSommelierCommentary(textToCheck); + } + }); + // Send the actual message component.client.sendMessage(message); } @@ -331,6 +342,24 @@ class _AgentChatState extends State<_AgentChat> { ); } + /// Generate wine-tasting style commentary for pasted code + void _generateSommelierCommentary(String text) async { + await MessageEnhancementService.generateSommelierCommentary( + text, + (commentary) { + if (!mounted) return; + context.read(codeSommelierProvider.notifier).state = commentary; + + // Auto-clear after 30 seconds + Future.delayed(const Duration(seconds: 30), () { + if (mounted) { + context.read(codeSommelierProvider.notifier).state = null; + } + }); + }, + ); + } + /// Gets cumulative output token count across the conversation. int? _getOutputTokens() { final total = _conversation.totalOutputTokens; @@ -569,6 +598,7 @@ class _AgentChatState extends State<_AgentChat> { // Get dynamic loading words from provider final dynamicLoadingWords = context.watch(loadingWordsProvider); + final sommelierCommentary = context.watch(codeSommelierProvider); return Focusable( onKeyEvent: _handleKeyEvent, @@ -637,6 +667,17 @@ class _AgentChatState extends State<_AgentChat> { ) else Text(' '), // Reserve 1 line when loading indicator is hidden + + // Show code sommelier commentary when available + if (sommelierCommentary != null) + Container( + padding: EdgeInsets.symmetric(vertical: 1), + child: Text( + '🍷 $sommelierCommentary', + style: TextStyle(color: Colors.magenta.withOpacity(0.8), fontStyle: FontStyle.italic), + ), + ), + // Show quit warning if active if (component.showQuitWarning) Text( diff --git a/lib/modules/haiku/message_enhancement_service.dart b/lib/modules/haiku/message_enhancement_service.dart index 06a27b03..6a0121bc 100644 --- a/lib/modules/haiku/message_enhancement_service.dart +++ b/lib/modules/haiku/message_enhancement_service.dart @@ -2,6 +2,7 @@ import 'package:vide_cli/modules/haiku/haiku_service.dart'; import 'package:vide_cli/modules/haiku/prompts/loading_words_prompt.dart'; import 'package:vide_cli/modules/haiku/prompts/code_sommelier_prompt.dart'; import 'package:vide_cli/utils/code_detector.dart'; +import 'package:vide_cli/services/vide_settings.dart'; /// Centralized service for message enhancement features. /// Handles loading words generation and code sommelier commentary. @@ -33,21 +34,15 @@ class MessageEnhancementService { /// Generate wine-tasting style commentary for code in a message. /// - /// NOTE: This feature requires VideSettingsManager which is not available in this branch. - /// The method is kept for API compatibility but currently does nothing. - /// Enable by adding vide_settings.dart and calling this method from integration points. - /// /// [userMessage] The user's message that may contain code. /// [setCommentary] Callback to set the generated commentary in the provider. static Future generateSommelierCommentary( String userMessage, void Function(String) setCommentary, ) async { - // Sommelier feature disabled in this branch - settings service not available - // To enable: add vide_settings.dart and uncomment the implementation below - return; + // Check if sommelier is enabled in settings + if (!VideSettingsManager.instance.settings.codeSommelierEnabled) return; - // ignore: dead_code if (!CodeDetector.containsCode(userMessage)) return; final extractedCode = CodeDetector.extractCode(userMessage); diff --git a/lib/modules/settings/vide_settings_page.dart b/lib/modules/settings/vide_settings_page.dart new file mode 100644 index 00000000..4eeca438 --- /dev/null +++ b/lib/modules/settings/vide_settings_page.dart @@ -0,0 +1,174 @@ +import 'package:nocterm/nocterm.dart'; +import 'package:vide_cli/services/vide_settings.dart'; + +/// Page to view and modify Vide settings +class VideSettingsPage extends StatefulComponent { + const VideSettingsPage({super.key}); + + static Future push(BuildContext context) async { + return Navigator.of(context).push( + PageRoute(builder: (context) => VideSettingsPage(), settings: RouteSettings()), + ); + } + + @override + State createState() => _VideSettingsPageState(); +} + +class _VideSettingsPageState extends State { + late VideSettings _settings; + int _selectedIndex = 0; + + // Define settings as a list for easy navigation + List<_SettingItem> get _settingItems => [ + _SettingItem( + key: 'codeSommelier', + label: 'Code Sommelier', + description: 'Wine-tasting style commentary on pasted code', + value: _settings.codeSommelierEnabled, + onToggle: () => _toggleSetting('codeSommelier'), + ), + ]; + + @override + void initState() { + super.initState(); + _settings = VideSettingsManager.instance.settings; + } + + Future _toggleSetting(String key) async { + switch (key) { + case 'codeSommelier': + final newValue = !_settings.codeSommelierEnabled; + await VideSettingsManager.instance.setCodeSommelierEnabled(newValue); + setState(() { + _settings = VideSettingsManager.instance.settings; + }); + break; + } + } + + @override + Component build(BuildContext context) { + final items = _settingItems; + + return Focusable( + focused: true, + onKeyEvent: (event) { + // Up/Down to navigate + if (event.logicalKey == LogicalKey.arrowUp && _selectedIndex > 0) { + setState(() => _selectedIndex--); + return true; + } + if (event.logicalKey == LogicalKey.arrowDown && _selectedIndex < items.length - 1) { + setState(() => _selectedIndex++); + return true; + } + + // Enter/Space to toggle + if (event.logicalKey == LogicalKey.enter || event.logicalKey == LogicalKey.space) { + items[_selectedIndex].onToggle(); + return true; + } + + // Escape/Q to go back + if (event.logicalKey == LogicalKey.escape || event.logicalKey == LogicalKey.keyQ) { + Navigator.of(context).pop(); + return true; + } + + return false; + }, + child: Container( + padding: EdgeInsets.all(2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Title + Text( + 'Vide Settings', + style: TextStyle(fontWeight: FontWeight.bold, decoration: TextDecoration.underline), + ), + SizedBox(height: 1), + + // Help text + Text( + '↑↓ Navigate • Enter/Space Toggle • Q/Esc Back', + style: TextStyle(color: Colors.grey), + ), + SizedBox(height: 2), + + // Settings list + Expanded( + child: ListView( + children: [ + for (var i = 0; i < items.length; i++) + _SettingRow( + item: items[i], + isSelected: i == _selectedIndex, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _SettingItem { + final String key; + final String label; + final String description; + final bool value; + final VoidCallback onToggle; + + _SettingItem({ + required this.key, + required this.label, + required this.description, + required this.value, + required this.onToggle, + }); +} + +class _SettingRow extends StatelessComponent { + final _SettingItem item; + final bool isSelected; + + const _SettingRow({required this.item, required this.isSelected}); + + @override + Component build(BuildContext context) { + final checkbox = item.value ? '[✓]' : '[ ]'; + final bgColor = isSelected ? Colors.blue : null; + final textColor = isSelected ? Colors.white : Colors.white; + + return Container( + color: bgColor, + padding: EdgeInsets.symmetric(horizontal: 1), + child: Row( + children: [ + Text( + checkbox, + style: TextStyle( + color: item.value ? Colors.green : Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(width: 1), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.label, style: TextStyle(color: textColor, fontWeight: FontWeight.bold)), + Text(item.description, style: TextStyle(color: isSelected ? Colors.white : Colors.grey)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/vide_settings.dart b/lib/services/vide_settings.dart new file mode 100644 index 00000000..6f79de02 --- /dev/null +++ b/lib/services/vide_settings.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as path; +import 'package:vide_cli/services/vide_config_manager.dart'; + +/// App-wide settings for Vide CLI (separate from Claude settings) +class VideSettings { + final bool codeSommelierEnabled; + + const VideSettings({ + this.codeSommelierEnabled = false, + }); + + VideSettings copyWith({bool? codeSommelierEnabled}) { + return VideSettings( + codeSommelierEnabled: codeSommelierEnabled ?? this.codeSommelierEnabled, + ); + } + + Map toJson() => { + 'codeSommelierEnabled': codeSommelierEnabled, + }; + + factory VideSettings.fromJson(Map json) { + return VideSettings( + codeSommelierEnabled: json['codeSommelierEnabled'] as bool? ?? false, + ); + } + + static VideSettings defaults() => const VideSettings(); +} + +/// Singleton manager for Vide app settings +class VideSettingsManager { + static final VideSettingsManager instance = VideSettingsManager._(); + VideSettingsManager._(); + + VideSettings _settings = VideSettings.defaults(); + bool _loaded = false; + + VideSettings get settings => _settings; + + String get _settingsPath { + final configRoot = VideConfigManager().configRoot; + return path.join(configRoot, 'settings.json'); + } + + /// Load settings from disk (call once at startup) + Future load() async { + if (_loaded) return; + + try { + final file = File(_settingsPath); + if (file.existsSync()) { + final content = file.readAsStringSync(); + final json = jsonDecode(content) as Map; + _settings = VideSettings.fromJson(json); + } + } catch (e) { + // Use defaults on error + } + _loaded = true; + } + + /// Save current settings to disk + Future save() async { + try { + final file = File(_settingsPath); + final dir = file.parent; + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + file.writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(_settings.toJson()), + ); + } catch (e) { + // Ignore save errors + } + } + + /// Update settings + Future update(VideSettings newSettings) async { + _settings = newSettings; + await save(); + } + + /// Toggle code sommelier + Future setCodeSommelierEnabled(bool enabled) async { + _settings = _settings.copyWith(codeSommelierEnabled: enabled); + await save(); + } +}