Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/components/attachment_text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -50,6 +53,7 @@ class AttachmentTextField extends StatefulComponent {
this.onCommand,
this.commandSuggestions,
this.onLeftEdge,
this.onChanged,
super.key,
});

Expand Down Expand Up @@ -85,6 +89,8 @@ class _AttachmentTextFieldState extends State<AttachmentTextField> {
setState(() {
_selectedSuggestionIndex = 0;
});
// Notify listener of text changes (for idle detection)
component.onChanged?.call(_controller.text);
}

List<CommandSuggestion> _getSuggestions() {
Expand Down
100 changes: 100 additions & 0 deletions lib/modules/agent_network/mixins/idle_detection_mixin.dart
Original file line number Diff line number Diff line change
@@ -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<T extends StatefulComponent> on State<T> {
// 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;
}
}
}
31 changes: 31 additions & 0 deletions lib/modules/haiku/prompts/idle_prompt.dart
Original file line number Diff line number Diff line change
@@ -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.';
}
}
}