diff --git a/lib/components/enhanced_loading_indicator.dart b/lib/components/enhanced_loading_indicator.dart index 5f55e31c..bdfe1bb0 100644 --- a/lib/components/enhanced_loading_indicator.dart +++ b/lib/components/enhanced_loading_indicator.dart @@ -5,7 +5,22 @@ import 'package:vide_cli/constants/text_opacity.dart'; import 'package:vide_cli/theme/theme.dart'; class EnhancedLoadingIndicator extends StatefulComponent { - const EnhancedLoadingIndicator({super.key}); + /// When the current response started (for elapsed time display) + final DateTime? responseStartTime; + + /// Current output token count (for token counter display) + final int? outputTokens; + + /// Dynamic loading words generated by Haiku based on user's message. + /// If provided and non-empty, cycles through these instead of static messages. + final List? dynamicWords; + + const EnhancedLoadingIndicator({ + super.key, + this.responseStartTime, + this.outputTokens, + this.dynamicWords, + }); @override State createState() => @@ -13,116 +28,164 @@ class EnhancedLoadingIndicator extends StatefulComponent { } class _EnhancedLoadingIndicatorState extends State { - static final _activities = [ - 'Calibrating quantum flux capacitors', - 'Teaching neurons to dance', - 'Counting electrons backwards', - 'Negotiating with the GPU', - 'Consulting the ancient scrolls', - 'Reticulating splines', - 'Downloading more RAM', - 'Asking the rubber duck for advice', - 'Warming up the hamster wheel', - 'Aligning chakras with CPU cores', - 'Bribing the cache', - 'Summoning the algorithm spirits', - 'Untangling virtual spaghetti', - 'Polishing the bits', - 'Feeding the neural network', - 'Optimizing the optimization', - 'Reversing entropy temporarily', - 'Borrowing cycles from the future', - 'Debugging the debugger', - 'Compiling thoughts into words', - 'Defragmenting consciousness', - 'Garbage collecting bad ideas', - 'Spinning up the thinking wheels', - 'Caffeinating the processors', - 'Consulting my digital crystal ball', - 'Performing ritual sacrifices to the memory gods', - 'Translating binary to feelings', - 'Mining for the perfect response', - 'Charging up the synaptic batteries', - 'Dusting off old neural pathways', - 'Waking up sleeping threads', - 'Organizing the chaos matrix', - 'Calibrating sarcasm levels', - 'Loading witty responses', - 'Searching the void for answers', - 'Petting the server hamsters', - 'Adjusting reality parameters', - 'Synchronizing with the cosmos', - 'Downloading wisdom from the cloud', - 'Recursively thinking about thinking', - 'Contemplating the meaning of bits', - 'Herding digital cats', - 'Shaking the magic 8-ball', - 'Tickling the silicon', - 'Whispering sweet nothings to the ALU', - 'Parsing the unparseable', - 'Finding the missing semicolon', - 'Dividing by zero carefully', - 'Counting to infinity twice', - 'Unscrambling quantum eggs', + static final _brailleFrames = [ + '\u280b', // ⠋ + '\u2819', // ⠙ + '\u2839', // ⠹ + '\u2838', // ⠸ + '\u283c', // ⠼ + '\u2834', // ⠴ + '\u2826', // ⠦ + '\u2827', // ⠧ + '\u2807', // ⠇ + '\u280f', // ⠏ ]; - static final _brailleFrames = [ - '⠋', - '⠙', - '⠹', - '⠸', - '⠼', - '⠴', - '⠦', - '⠧', - '⠇', - '⠏', + static final _fallbackMessages = [ + 'Reticulating splines', + 'Consulting the oracle', + 'Herding electrons', + 'Polishing the bits', + 'Warming up the neurons', + 'Untangling the logic', + 'Feeding the hamsters', + 'Brewing some code', + 'Thinking really hard', + 'Consulting ancient scrolls', + 'Channeling the machine spirit', + 'Waking up the minions', + 'Spinning up the gerbils', + 'Pondering the imponderables', + 'Aligning the chakras', + 'Summoning the algorithms', + 'Charging the flux capacitor', + 'Parsing the cosmos', + 'Calibrating the quantum', + 'Assembling the thoughts', ]; - final _random = Random(); Timer? _animationTimer; - Timer? _activityTimer; + Timer? _messageTimer; int _frameIndex = 0; - int _activityIndex = 0; int _shimmerPosition = 0; + String _currentMessage = ''; + int _dynamicWordIndex = 0; + bool _hasDynamicWords = false; @override void initState() { super.initState(); - _activityIndex = _random.nextInt(_activities.length); + _initializeMessage(); // Animation timer for braille and shimmer _animationTimer = Timer.periodic(Duration(milliseconds: 100), (_) { setState(() { _frameIndex = (_frameIndex + 1) % _brailleFrames.length; _shimmerPosition = (_shimmerPosition + 1); - if (_shimmerPosition >= _activities[_activityIndex].length + 5) { + if (_shimmerPosition >= _currentMessage.length + 5) { _shimmerPosition = -5; } }); }); + } + + void _initializeMessage() { + final dynamicWords = component.dynamicWords; + if (dynamicWords != null && dynamicWords.isNotEmpty) { + // We have dynamic words - use them and start cycling + _hasDynamicWords = true; + _dynamicWordIndex = 0; + _currentMessage = dynamicWords[0]; + _startMessageTimer(); + } else { + // No dynamic words yet - pick one random static message and stick with it + _hasDynamicWords = false; + final random = Random(); + _currentMessage = _fallbackMessages[random.nextInt(_fallbackMessages.length)]; + // Don't start timer - we stay on this message until dynamic words arrive + } + } - // Activity change timer - _activityTimer = Timer.periodic(Duration(seconds: 4), (_) { + void _startMessageTimer() { + _messageTimer?.cancel(); + // Cycle through dynamic words every 10 seconds + _messageTimer = Timer.periodic(Duration(seconds: 10), (_) { setState(() { - _activityIndex = _random.nextInt(_activities.length); + _pickNextDynamicWord(); _shimmerPosition = -5; }); }); } + @override + void didUpdateComponent(EnhancedLoadingIndicator oldComponent) { + super.didUpdateComponent(oldComponent); + + // If dynamic words just became available, start using them + if (component.dynamicWords != null && + component.dynamicWords!.isNotEmpty && + !_hasDynamicWords) { + _hasDynamicWords = true; + _dynamicWordIndex = 0; + _currentMessage = component.dynamicWords![0]; + _shimmerPosition = -5; + _startMessageTimer(); + } + } + + void _pickNextDynamicWord() { + final dynamicWords = component.dynamicWords; + if (dynamicWords != null && dynamicWords.isNotEmpty) { + _dynamicWordIndex = (_dynamicWordIndex + 1) % dynamicWords.length; + _currentMessage = dynamicWords[_dynamicWordIndex]; + } + } + @override void dispose() { _animationTimer?.cancel(); - _activityTimer?.cancel(); + _messageTimer?.cancel(); super.dispose(); } + String _formatElapsedTime() { + if (component.responseStartTime == null) return ''; + final elapsed = DateTime.now().difference(component.responseStartTime!); + final seconds = elapsed.inSeconds; + if (seconds < 60) { + return '${seconds}s'; + } else { + final minutes = elapsed.inMinutes; + final remainingSeconds = seconds % 60; + return '${minutes}m ${remainingSeconds}s'; + } + } + + String _formatTokens(int tokens) { + if (tokens >= 1000) { + final k = tokens / 1000; + return '${k.toStringAsFixed(1)}k'; + } + return tokens.toString(); + } + @override Component build(BuildContext context) { final theme = VideTheme.of(context); final braille = _brailleFrames[_frameIndex]; - final activity = _activities[_activityIndex]; + + // Build the status info parts + final statusParts = []; + + // Add elapsed time if we have a start time + if (component.responseStartTime != null) { + statusParts.add(_formatElapsedTime()); + } + + // Add token count if available + if (component.outputTokens != null && component.outputTokens! > 0) { + statusParts.add('\u2193 ${_formatTokens(component.outputTokens!)} tokens'); + } return Row( children: [ @@ -135,7 +198,17 @@ class _EnhancedLoadingIndicatorState extends State { ), SizedBox(width: 1), // Activity text with shimmer - _buildShimmerText(context, activity), + _buildShimmerText(context, _currentMessage), + // Show elapsed time and tokens + if (statusParts.isNotEmpty) ...[ + SizedBox(width: 1), + Text( + '(${statusParts.join(' \u00b7 ')})', + style: TextStyle( + color: Colors.white.withOpacity(TextOpacity.tertiary), + ), + ), + ], ], ); } @@ -160,3 +233,51 @@ class _EnhancedLoadingIndicatorState extends State { return Row(mainAxisSize: MainAxisSize.min, children: components); } } + +/// A simpler thinking indicator that just shows duration +class ThinkingIndicator extends StatefulComponent { + final DateTime? startTime; + + const ThinkingIndicator({super.key, this.startTime}); + + @override + State createState() => _ThinkingIndicatorState(); +} + +class _ThinkingIndicatorState extends State { + Timer? _timer; + int _seconds = 0; + + @override + void initState() { + super.initState(); + _updateSeconds(); + _timer = Timer.periodic(Duration(seconds: 1), (_) { + setState(() { + _updateSeconds(); + }); + }); + } + + void _updateSeconds() { + if (component.startTime != null) { + _seconds = DateTime.now().difference(component.startTime!).inSeconds; + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Component build(BuildContext context) { + return Text( + 'Thought for ${_seconds}s', + style: TextStyle( + color: Colors.white.withOpacity(TextOpacity.tertiary), + ), + ); + } +} diff --git a/lib/modules/agent_network/components/message_renderer.dart b/lib/modules/agent_network/components/message_renderer.dart new file mode 100644 index 00000000..14a9b735 --- /dev/null +++ b/lib/modules/agent_network/components/message_renderer.dart @@ -0,0 +1,147 @@ +import 'package:nocterm/nocterm.dart'; +import 'package:claude_api/claude_api.dart'; +import 'package:vide_cli/components/enhanced_loading_indicator.dart'; +import 'package:vide_cli/components/tool_invocations/tool_invocation_router.dart'; +import 'package:vide_cli/constants/text_opacity.dart'; +import 'package:vide_cli/modules/agent_network/state/agent_response_times.dart'; + +/// Renders a single conversation message (user or assistant). +class MessageRenderer extends StatelessComponent { + /// The message to render + final ConversationMessage message; + + /// Dynamic loading words for enhanced loading indicator + final List? dynamicLoadingWords; + + /// Agent session ID for response time lookups + final String agentSessionId; + + /// Working directory for tool invocations + final String workingDirectory; + + /// Network/execution ID for tool invocations + final String executionId; + + /// Current output token count (for loading indicator) + final int? outputTokens; + + const MessageRenderer({ + super.key, + required this.message, + required this.agentSessionId, + required this.workingDirectory, + required this.executionId, + this.dynamicLoadingWords, + this.outputTokens, + }); + + @override + Component build(BuildContext context) { + if (message.role == MessageRole.user) { + return Container( + padding: EdgeInsets.only(bottom: 1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('> ${message.content}', style: TextStyle(color: Colors.white)), + if (message.attachments != null && message.attachments!.isNotEmpty) + for (var attachment in message.attachments!) + Text( + ' 📎 ${attachment.path ?? "image"}', + style: TextStyle(color: Colors.white.withOpacity(TextOpacity.secondary)), + ), + ], + ), + ); + } else { + // Build tool invocations by pairing calls with their results + final toolCallsById = {}; + final toolResultsById = {}; + + // First pass: collect all tool calls and results by ID + for (final response in message.responses) { + if (response is ToolUseResponse && response.toolUseId != null) { + toolCallsById[response.toolUseId!] = response; + } else if (response is ToolResultResponse) { + toolResultsById[response.toolUseId] = response; + } + } + + // Second pass: render responses in order, combining tool calls with their results + final widgets = []; + final renderedToolResults = {}; + + for (final response in message.responses) { + if (response is TextResponse) { + if (response.content.isEmpty && message.isStreaming) { + widgets.add(EnhancedLoadingIndicator( + responseStartTime: AgentResponseTimes.get(agentSessionId), + outputTokens: outputTokens, + dynamicWords: dynamicLoadingWords, + )); + } else { + widgets.add(MarkdownText(response.content)); + } + } else if (response is ToolUseResponse) { + // Check if we have a result for this tool call + final result = response.toolUseId != null ? toolResultsById[response.toolUseId] : null; + + String? subagentSessionId; + + // Use factory method to create typed invocation + final invocation = ConversationMessage.createTypedInvocation(response, result, sessionId: subagentSessionId); + + widgets.add( + ToolInvocationRouter( + key: ValueKey(response.toolUseId ?? response.id), + invocation: invocation, + workingDirectory: workingDirectory, + executionId: executionId, + agentId: agentSessionId, + ), + ); + if (result != null && response.toolUseId != null) { + renderedToolResults.add(response.toolUseId!); + } + } else if (response is ToolResultResponse) { + // Only show tool result if it wasn't already paired with its call + if (!renderedToolResults.contains(response.toolUseId)) { + // This is an orphaned tool result (shouldn't normally happen) + widgets.add( + Container( + padding: EdgeInsets.only(left: 2, top: 1), + child: Text('[orphaned result: ${response.content}]', style: TextStyle(color: Colors.red)), + ), + ); + } + } + } + + return Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...widgets, + + // If no responses yet but streaming, show loading + if (message.responses.isEmpty && message.isStreaming) + EnhancedLoadingIndicator( + responseStartTime: AgentResponseTimes.get(agentSessionId), + outputTokens: outputTokens, + dynamicWords: dynamicLoadingWords, + ), + + if (message.error != null) + Container( + padding: EdgeInsets.only(left: 2, top: 1), + child: Text( + '[error: ${message.error}]', + style: TextStyle(color: Colors.white.withOpacity(TextOpacity.secondary)), + ), + ), + ], + ), + ); + } + } +} diff --git a/lib/modules/agent_network/components/session_token_counter.dart b/lib/modules/agent_network/components/session_token_counter.dart new file mode 100644 index 00000000..5e6425a3 --- /dev/null +++ b/lib/modules/agent_network/components/session_token_counter.dart @@ -0,0 +1,35 @@ +import 'package:nocterm/nocterm.dart'; +import 'package:nocterm_riverpod/nocterm_riverpod.dart'; +import 'package:vide_cli/modules/haiku/haiku_providers.dart'; + +/// Displays session token usage in the bottom right corner +class SessionTokenCounter extends StatelessComponent { + const SessionTokenCounter({super.key}); + + String _formatTokens(int tokens) { + if (tokens >= 1000000) { + return '${(tokens / 1000000).toStringAsFixed(1)}M'; + } else if (tokens >= 1000) { + return '${(tokens / 1000).toStringAsFixed(1)}k'; + } + return tokens.toString(); + } + + @override + Component build(BuildContext context) { + final usage = context.watch(sessionTokenUsageProvider); + + // Don't show if no tokens used yet + if (usage.totalTokens == 0) return SizedBox(); + + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '↑${_formatTokens(usage.inputTokens)} ↓${_formatTokens(usage.outputTokens)}', + style: TextStyle(color: Colors.white.withOpacity(0.3)), + ), + ], + ); + } +} diff --git a/lib/modules/agent_network/network_execution_page.dart b/lib/modules/agent_network/network_execution_page.dart index 8af9f4e4..b48f6f7d 100644 --- a/lib/modules/agent_network/network_execution_page.dart +++ b/lib/modules/agent_network/network_execution_page.dart @@ -15,9 +15,12 @@ import 'package:vide_cli/constants/text_opacity.dart'; import 'package:vide_cli/main.dart'; import 'package:vide_cli/modules/agent_network/components/running_agents_bar.dart'; import 'package:vide_cli/modules/agent_network/components/context_usage_bar.dart'; +import 'package:vide_cli/modules/agent_network/state/agent_response_times.dart'; import 'package:vide_cli/components/git_branch_indicator.dart'; import 'package:vide_cli/modules/commands/command.dart'; import 'package:vide_cli/modules/commands/command_provider.dart'; +import 'package:vide_cli/modules/haiku/haiku_providers.dart'; +import 'package:vide_cli/modules/haiku/message_enhancement_service.dart'; import 'package:vide_core/vide_core.dart'; import 'package:vide_cli/modules/permissions/permission_service.dart'; import 'package:vide_cli/theme/theme.dart'; @@ -243,6 +246,9 @@ class _AgentChatState extends State<_AgentChat> { bool _commandResultIsError = false; String? _queuedMessage; + // Track conversation state changes for response timing + ConversationState? _lastConversationState; + @override void initState() { super.initState(); @@ -251,6 +257,17 @@ class _AgentChatState extends State<_AgentChat> { _conversationSubscription = component.client.conversation.listen(( conversation, ) { + // Track when response starts/stops for elapsed time display + if (conversation.state == ConversationState.receivingResponse && + _lastConversationState != ConversationState.receivingResponse) { + AgentResponseTimes.startIfNeeded(component.client.sessionId); + } else if (_lastConversationState == ConversationState.receivingResponse && + conversation.state != ConversationState.receivingResponse) { + AgentResponseTimes.clear(component.client.sessionId); + } + + _lastConversationState = conversation.state; + setState(() { _conversation = conversation; }); @@ -259,6 +276,12 @@ class _AgentChatState extends State<_AgentChat> { _syncTokenStats(conversation); }); _conversation = component.client.currentConversation; + _lastConversationState = _conversation.state; + + // If already receiving response when we init, ensure start time is tracked + if (_conversation.state == ConversationState.receivingResponse) { + AgentResponseTimes.startIfNeeded(component.client.sessionId); + } // Listen to queued message updates _queueSubscription = component.client.queuedMessage.listen((text) { @@ -289,9 +312,31 @@ class _AgentChatState extends State<_AgentChat> { } void _sendMessage(Message message) { + // Generate creative loading words with Haiku in the background + _generateLoadingWords(message.text); + + // Send the actual message component.client.sendMessage(message); } + /// Helper to generate loading words using MessageEnhancementService + void _generateLoadingWords(String userMessage) async { + await MessageEnhancementService.generateLoadingWords( + userMessage, + (words) { + if (mounted) { + context.read(loadingWordsProvider.notifier).state = words; + } + }, + ); + } + + /// Gets cumulative output token count across the conversation. + int? _getOutputTokens() { + final total = _conversation.totalOutputTokens; + return total > 0 ? total : null; + } + Future _handleCommand(String commandInput) async { final dispatcher = context.read(commandDispatcherProvider); final commandContext = CommandContext( @@ -522,6 +567,9 @@ class _AgentChatState extends State<_AgentChat> { ); final currentAskUserQuestionRequest = askUserQuestionQueueState.current; + // Get dynamic loading words from provider + final dynamicLoadingWords = context.watch(loadingWordsProvider); + return Focusable( onKeyEvent: _handleKeyEvent, focused: true, @@ -571,7 +619,11 @@ class _AgentChatState extends State<_AgentChat> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - EnhancedLoadingIndicator(), + EnhancedLoadingIndicator( + responseStartTime: AgentResponseTimes.get(component.client.sessionId), + outputTokens: _getOutputTokens(), + dynamicWords: dynamicLoadingWords, + ), SizedBox(width: 2), Text( '(Press ESC to stop)', diff --git a/lib/modules/agent_network/state/agent_response_times.dart b/lib/modules/agent_network/state/agent_response_times.dart new file mode 100644 index 00000000..d1c1ebca --- /dev/null +++ b/lib/modules/agent_network/state/agent_response_times.dart @@ -0,0 +1,18 @@ +/// Shared storage for response start times per agent (persists across tab switches) +/// Used by both RunningAgentsBar and NetworkExecutionPage +class AgentResponseTimes { + static final Map _times = {}; + + /// Get the response start time for an agent, or null if not processing + static DateTime? get(String agentId) => _times[agentId]; + + /// Set the response start time for an agent (only if not already set) + static void startIfNeeded(String agentId) { + _times.putIfAbsent(agentId, () => DateTime.now()); + } + + /// Clear the response start time for an agent + static void clear(String agentId) { + _times.remove(agentId); + } +} diff --git a/lib/modules/haiku/haiku_providers.dart b/lib/modules/haiku/haiku_providers.dart new file mode 100644 index 00000000..bd30f39d --- /dev/null +++ b/lib/modules/haiku/haiku_providers.dart @@ -0,0 +1,50 @@ +import 'package:nocterm_riverpod/nocterm_riverpod.dart'; + +/// Dynamic loading words - shown during agent processing +final loadingWordsProvider = StateProvider?>((ref) => null); + +/// Dynamic placeholder text for input field +final placeholderTextProvider = StateProvider((ref) => null); + +/// Idle detector message - shown after inactivity +final idleMessageProvider = StateProvider((ref) => null); + +/// Activity tip - shown during long operations +final activityTipProvider = StateProvider((ref) => null); + +/// Code sommelier commentary +final codeSommelierProvider = StateProvider((ref) => null); + +/// Sub-agent progress summary +final agentProgressSummaryProvider = StateProvider((ref) => null); + +/// Code change summary +final changeSummaryProvider = StateProvider((ref) => null); + +/// Error triage result +final errorTriageProvider = StateProvider((ref) => null); + +/// Task complexity estimate +final complexityEstimateProvider = StateProvider((ref) => null); + +/// Long response TL;DR +final tldrProvider = StateProvider((ref) => null); + +/// Session token usage tracking +class SessionTokenUsage { + final int inputTokens; + final int outputTokens; + + const SessionTokenUsage({this.inputTokens = 0, this.outputTokens = 0}); + + int get totalTokens => inputTokens + outputTokens; + + SessionTokenUsage add({int input = 0, int output = 0}) { + return SessionTokenUsage( + inputTokens: inputTokens + input, + outputTokens: outputTokens + output, + ); + } +} + +final sessionTokenUsageProvider = StateProvider((ref) => const SessionTokenUsage()); diff --git a/lib/modules/haiku/haiku_service.dart b/lib/modules/haiku/haiku_service.dart new file mode 100644 index 00000000..df7d7e15 --- /dev/null +++ b/lib/modules/haiku/haiku_service.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Core service for running Claude Haiku background tasks. +/// All Haiku-powered features share this infrastructure. +class HaikuService { + /// Enable/disable logging for debugging + static bool enableLogging = Platform.environment['VIDE_DEBUG_HAIKU'] == '1'; + + /// Default configuration + static const Duration defaultDelay = Duration(milliseconds: 500); + static const Duration defaultTimeout = Duration(seconds: 10); + + static File? _logFile; + + /// Generic Haiku invocation - foundation for all features + /// Returns null on any failure (graceful degradation) + static Future invoke({ + required String systemPrompt, + required String userMessage, + Duration delay = defaultDelay, + Duration timeout = defaultTimeout, + }) async { + _log('invoke called with message: "${_truncate(userMessage, 50)}"'); + + try { + // Small delay to let main Claude process initialize first + if (delay.inMilliseconds > 0) { + await Future.delayed(delay); + } + + final process = await Process.start( + 'claude', + [ + '-p', userMessage, + '--model', 'claude-haiku-4-5-20251001', + '--system-prompt', systemPrompt, + '--output-format', 'text', + '--max-turns', '1', + ], + environment: {'MCP_TOOL_TIMEOUT': '30000000'}, + runInShell: true, + includeParentEnvironment: true, + ); + + _log('Haiku process started with PID: ${process.pid}'); + + await process.stdin.close(); + + final stdoutFuture = process.stdout.transform(utf8.decoder).join(); + final stderrFuture = process.stderr.transform(utf8.decoder).join(); + + final results = await Future.wait([ + stdoutFuture, + stderrFuture, + process.exitCode, + ]).timeout(timeout); + + final stdout = results[0] as String; + final stderr = results[1] as String; + final exitCode = results[2] as int; + + _log('Haiku exit code: $exitCode'); + + if (exitCode != 0) { + _log('Haiku error: $stderr'); + return null; + } + + final text = stdout.trim(); + _log('Haiku response: ${_truncate(text, 200)}'); + + // Filter out error messages that come through stdout + if (text.isEmpty) return null; + if (text.startsWith('Error:')) return null; + if (text.contains('Reached max turns')) return null; + if (text.contains('rate limit')) return null; + if (text.contains('API error')) return null; + + return text; + } on TimeoutException { + _log('Haiku timed out'); + return null; + } catch (e) { + _log('Haiku error: $e'); + return null; + } + } + + /// Specialized invocation for list-based outputs (loading words, tips, etc.) + static Future?> invokeForList({ + required String systemPrompt, + required String userMessage, + String lineEnding = '...', + int maxItems = 5, + Duration delay = defaultDelay, + Duration timeout = defaultTimeout, + }) async { + final result = await invoke( + systemPrompt: systemPrompt, + userMessage: userMessage, + delay: delay, + timeout: timeout, + ); + + if (result == null) return null; + + final lines = result + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .map((line) => line.endsWith(lineEnding) ? line : '$line$lineEnding') + .take(maxItems) + .toList(); + + return lines.isEmpty ? null : lines; + } + + static void _log(String message) { + if (!enableLogging) return; + + final timestamp = DateTime.now().toIso8601String(); + final logLine = '[$timestamp] [HaikuService] $message\n'; + + _logFile ??= File('/tmp/vide_haiku.log'); + _logFile!.writeAsStringSync(logLine, mode: FileMode.append); + } + + static String _truncate(String s, int maxLength) { + if (s.length <= maxLength) return s; + return '${s.substring(0, maxLength)}...'; + } +} diff --git a/lib/modules/haiku/message_enhancement_service.dart b/lib/modules/haiku/message_enhancement_service.dart new file mode 100644 index 00000000..06a27b03 --- /dev/null +++ b/lib/modules/haiku/message_enhancement_service.dart @@ -0,0 +1,68 @@ +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'; + +/// Centralized service for message enhancement features. +/// Handles loading words generation and code sommelier commentary. +/// +/// Uses a callback-based API so callers can provide their own way to +/// set the provider state (supporting both Ref and BuildContext usage). +class MessageEnhancementService { + /// Generate creative loading words for a user message. + /// + /// [userMessage] The user's message to generate loading words for. + /// [setLoadingWords] Callback to set the generated words in the provider. + static Future generateLoadingWords( + String userMessage, + void Function(List) setLoadingWords, + ) async { + final systemPrompt = LoadingWordsPrompt.build(DateTime.now()); + final wrappedMessage = 'Generate loading words for this task: "$userMessage"'; + + final words = await HaikuService.invokeForList( + systemPrompt: systemPrompt, + userMessage: wrappedMessage, + lineEnding: '...', + maxItems: 5, + ); + if (words != null) { + setLoadingWords(words); + } + } + + /// 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; + + // ignore: dead_code + if (!CodeDetector.containsCode(userMessage)) return; + + final extractedCode = CodeDetector.extractCode(userMessage); + final truncatedCode = extractedCode.length > 2000 + ? '${extractedCode.substring(0, 2000)}...' + : extractedCode; + final systemPrompt = CodeSommelierPrompt.build(truncatedCode); + + final commentary = await HaikuService.invoke( + systemPrompt: systemPrompt, + userMessage: 'Analyze this code.', + ); + + if (commentary != null) { + setCommentary(commentary); + } + } +} diff --git a/lib/modules/haiku/prompts/code_sommelier_prompt.dart b/lib/modules/haiku/prompts/code_sommelier_prompt.dart new file mode 100644 index 00000000..a8a3e254 --- /dev/null +++ b/lib/modules/haiku/prompts/code_sommelier_prompt.dart @@ -0,0 +1,24 @@ +/// Prompt builder for code sommelier analysis - wine-tasting style code review. +class CodeSommelierPrompt { + static String build(String codeSnippet) { + return ''' +You are a code sommelier - you analyze code like a wine expert analyzes wine. + +CODE TO ANALYZE: +``` +$codeSnippet +``` + +RULES: +- ONE SENTENCE ONLY (15-25 words max) +- Wine-tasting style with "notes", "vintage", or "finish" +- Examples: + - "Notes of copy-pasted Stack Overflow with a 2019 vintage finish—pairs well with regret." + - "A bold oaky aroma of 'written at 3am' with undertones of deadline panic." + - "Detecting hints of 'will rename later' with a crisp defensive aftertaste." +- Dry wit, understated—never mean-spirited +- No emojis +- Output ONLY the single sentence, nothing else +'''; + } +} diff --git a/lib/modules/haiku/prompts/loading_words_prompt.dart b/lib/modules/haiku/prompts/loading_words_prompt.dart new file mode 100644 index 00000000..b2e6fd2c --- /dev/null +++ b/lib/modules/haiku/prompts/loading_words_prompt.dart @@ -0,0 +1,21 @@ +/// Prompt builder for loading words +class LoadingWordsPrompt { + /// The exact prompt that was working well + static String build([DateTime? _]) { + return ''' +You are a loading message generator. Output 5 fun, satirical loading messages. + +RULES: +- Output EXACTLY 5 messages, one per line +- Do NOT end with "..." (ellipsis will be added automatically) +- Each message should be 2-4 words total +- Each message must include at least ONE whimsical made-up word +- Prefer fake verbs ending in -ating or -ling +- You MAY add a short real-word phrase for contrast +- Tone: dry, self-aware, gently sarcastic +- Humor should feel intentional, not random +- No emojis, no memes, no AI references +- NO explanations - output only the 5 messages +'''; + } +} diff --git a/lib/utils/code_detector.dart b/lib/utils/code_detector.dart new file mode 100644 index 00000000..6df788d3 --- /dev/null +++ b/lib/utils/code_detector.dart @@ -0,0 +1,66 @@ +/// Detects if text contains code snippets +class CodeDetector { + /// Check if the text likely contains code + /// Returns true if code patterns are detected + static bool containsCode(String text) { + if (text.length < 20) return false; + + // Check for markdown code blocks + if (text.contains('```')) return true; + + // Check for common code patterns + final codePatterns = [ + RegExp(r'function\s+\w+\s*\('), // JS function declarations + RegExp(r'def\s+\w+\s*\('), // Python functions + RegExp(r'\bvoid\s+\w+\s*\('), // C/Java/Dart void functions + RegExp(r'\b(int|string|bool|double|float|char)\s+\w+\s*\('), // typed functions + RegExp(r'class\s+\w+'), // class declarations + RegExp(r'=>'), // arrow functions/expressions + RegExp(r'\bconst\s+\w+\s*='), // const declarations + RegExp(r'\bfinal\s+\w+\s*='), // Dart final declarations + RegExp(r'\blet\s+\w+\s*='), // let declarations + RegExp(r'\bvar\s+\w+\s*='), // var declarations + RegExp(r'''import\s+['"]'''), // JS/Dart imports + RegExp(r"import\s+'package:"), // Dart package imports + RegExp(r'''from\s+['"].*['"]\s+import'''), // Python imports + RegExp(r'#include\s*<'), // C/C++ includes + RegExp(r'\bif\s*\('), // if statements + RegExp(r'\bfor\s*\('), // for loops + RegExp(r'\bwhile\s*\('), // while loops + RegExp(r'\breturn\s+'), // return statements + RegExp(r'@\w+', multiLine: true), // decorators/annotations + RegExp(r'^\s*}\s*$', multiLine: true), // closing braces on own line + RegExp(r'\w+\.\w+\('), // method calls + RegExp(r';\s*$', multiLine: true), // semicolon line endings + ]; + + int matches = 0; + for (final pattern in codePatterns) { + if (pattern.hasMatch(text)) { + matches++; + if (matches >= 2) return true; // Need at least 2 patterns + } + } + + // Check for high density of special characters common in code + final codeChars = text.split('').where((c) => + ['{', '}', '(', ')', '[', ']', ';', '=', '<', '>', ':'].contains(c) + ).length; + + if (codeChars > text.length * 0.05) return true; // >5% code chars (lowered threshold) + + return false; + } + + /// Extract code from markdown code blocks, or return full text if no blocks + static String extractCode(String text) { + final codeBlockRegex = RegExp(r'```(?:\w*\n)?([\s\S]*?)```'); + final matches = codeBlockRegex.allMatches(text); + + if (matches.isNotEmpty) { + return matches.map((m) => m.group(1)?.trim() ?? '').join('\n\n'); + } + + return text; + } +}