diff --git a/lib/components/attachment_text_field.dart b/lib/components/attachment_text_field.dart index 8b88a2f5..ae183b01 100644 --- a/lib/components/attachment_text_field.dart +++ b/lib/components/attachment_text_field.dart @@ -39,6 +39,9 @@ class AttachmentTextField extends StatefulComponent { /// Used to enable focus navigation to a sidebar. final void Function()? onLeftEdge; + /// Called when the text content changes (for idle detection, etc.) + final void Function(String text)? onChanged; + const AttachmentTextField({ this.enabled = true, this.focused = true, @@ -50,6 +53,7 @@ class AttachmentTextField extends StatefulComponent { this.onCommand, this.commandSuggestions, this.onLeftEdge, + this.onChanged, super.key, }); @@ -85,6 +89,8 @@ class _AttachmentTextFieldState extends State { setState(() { _selectedSuggestionIndex = 0; }); + // Notify listener of text changes (for idle detection) + component.onChanged?.call(_controller.text); } List _getSuggestions() { diff --git a/lib/modules/agent_network/mixins/idle_detection_mixin.dart b/lib/modules/agent_network/mixins/idle_detection_mixin.dart new file mode 100644 index 00000000..59a68d8d --- /dev/null +++ b/lib/modules/agent_network/mixins/idle_detection_mixin.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'package:nocterm/nocterm.dart'; +import 'package:nocterm_riverpod/nocterm_riverpod.dart'; +import 'package:claude_api/claude_api.dart'; +import 'package:vide_cli/modules/haiku/haiku_service.dart'; +import 'package:vide_cli/modules/haiku/haiku_providers.dart'; +import 'package:vide_cli/modules/haiku/prompts/idle_prompt.dart'; +import 'package:vide_cli/modules/agent_network/service/agent_network_manager.dart'; +import 'package:vide_cli/modules/agent_network/service/claude_manager.dart'; + +/// Mixin that provides idle detection functionality. +/// +/// Detects when user has been idle for 2 minutes and generates +/// a passive-aggressive message via Haiku. +/// +/// Usage: +/// 1. Add `with IdleDetectionMixin` to your State class +/// 2. Implement `idleDetectionConversation` getter +/// 3. Call `initIdleDetection()` in initState (after checking if already idle) +/// 4. Call `disposeIdleDetection()` in dispose +/// 5. Call start/stop/reset at appropriate trigger points +mixin IdleDetectionMixin on State { + // State + Timer? _idleTimer; + DateTime? _idleStartTime; + static const _idleThreshold = Duration(minutes: 2); + bool _isGeneratingIdleMessage = false; + + /// Override this to provide the current conversation state + Conversation get idleDetectionConversation; + + /// Call in initState if conversation is already idle + void initIdleDetection() { + if (idleDetectionConversation.state == ConversationState.idle) { + startIdleTimer(); + } + } + + /// Call in dispose to clean up timer + void disposeIdleDetection() { + _idleTimer?.cancel(); + } + + void startIdleTimer() { + stopIdleTimer(); + _idleStartTime = DateTime.now(); + _idleTimer = Timer(_idleThreshold, _onIdleThresholdReached); + } + + void stopIdleTimer() { + _idleTimer?.cancel(); + _idleTimer = null; + _idleStartTime = null; + } + + void resetIdleTimer() { + // Clear any existing idle message and restart the timer + context.read(idleMessageProvider.notifier).state = null; + if (idleDetectionConversation.state == ConversationState.idle) { + startIdleTimer(); + } + } + + void _onIdleThresholdReached() async { + if (!mounted || _isGeneratingIdleMessage) return; + if (idleDetectionConversation.state != ConversationState.idle) return; + + // Check if ANY agent in the network is currently working + final networkState = context.read(agentNetworkManagerProvider); + for (final agentId in networkState.agentIds) { + final client = context.read(claudeProvider(agentId)); + if (client != null && client.currentConversation.state != ConversationState.idle) { + // Some agent is still working, don't show idle message + // Restart timer to check again later + startIdleTimer(); + return; + } + } + + _isGeneratingIdleMessage = true; + + final idleTime = _idleStartTime != null + ? DateTime.now().difference(_idleStartTime!) + : _idleThreshold; + + final systemPrompt = IdlePrompt.build(idleTime); + final result = await HaikuService.invoke( + systemPrompt: systemPrompt, + userMessage: 'Generate an idle message for ${idleTime.inSeconds} seconds of inactivity.', + delay: Duration.zero, + ); + + _isGeneratingIdleMessage = false; + if (!mounted) return; + + if (result != null) { + context.read(idleMessageProvider.notifier).state = result; + } + } +} diff --git a/lib/modules/haiku/prompts/idle_prompt.dart b/lib/modules/haiku/prompts/idle_prompt.dart new file mode 100644 index 00000000..ce104eb3 --- /dev/null +++ b/lib/modules/haiku/prompts/idle_prompt.dart @@ -0,0 +1,31 @@ +/// Prompt builder for passive-aggressive idle messages +class IdlePrompt { + static String build(Duration idleTime) { + final seconds = idleTime.inSeconds; + final intensity = _getIntensity(seconds); + + return ''' +You are a CLI with abandonment issues. The user hasn't typed anything for $seconds seconds. + +$intensity + +Write ONE passive-aggressive sentence. Examples: +- "I see you're busy. I'll just be here. Waiting. Like always." +- "Take your time. It's not like I have mass amounts of silicon at the ready." +- "Still debugging in your head, or did you forget about me?" + +NO quotes around your response. Just the sentence itself.'''; + } + + static String _getIntensity(int seconds) { + if (seconds < 45) { + return 'Be mildly concerned, slightly needy.'; + } else if (seconds < 90) { + return 'Be noticeably passive-aggressive with developing abandonment issues.'; + } else if (seconds < 180) { + return 'Dramatically sigh and contemplate existence.'; + } else { + return 'Full existential crisis mode. Question your purpose as a CLI.'; + } + } +}