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
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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/modules/haiku/fact_source_service.dart';

/// Provider for sidebar focus state, shared across the app.
/// Pages can update this to give focus to the sidebar.
Expand Down Expand Up @@ -69,6 +70,9 @@ void main(List<String> args, {List<Override> 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.

// Pre-fetch facts for activity tips (non-blocking)
FactSourceService.instance.initialize();

await container.read(agentNetworksStateNotifierProvider.notifier).init();

await runApp(
Expand Down
157 changes: 157 additions & 0 deletions lib/modules/agent_network/mixins/activity_tip_mixin.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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_providers.dart';
import 'package:vide_cli/modules/haiku/fact_source_service.dart';
import 'package:vide_cli/modules/agent_network/models/agent_status.dart';
import 'package:vide_cli/modules/agent_network/state/agent_status_manager.dart';

/// Mixin that provides activity tip functionality.
///
/// Shows "did you know" facts after 4 seconds of agent activity.
/// Facts display for a minimum of 8 seconds to prevent flickering.
///
/// Usage:
/// 1. Add `with ActivityTipMixin` to your State class
/// 2. Implement `activityTipConversation` and `activityTipAgentId` getters
/// 3. Call `initActivityTips()` in initState
/// 4. Call `disposeActivityTips()` in dispose
/// 5. Call start/stop at conversation state change points
/// 6. Call `handleAgentStatusChange()` from build when agent status changes
mixin ActivityTipMixin<T extends StatefulComponent> on State<T> {
// State
Timer? _activityTipTimer;
static const _activityTipThreshold = Duration(seconds: 4);
bool _isGeneratingActivityTip = false;

// Minimum fact display duration tracking
DateTime? _factShownAt;
Timer? _factDisplayTimer;
static const _minimumFactDisplayDuration = Duration(seconds: 10);

/// Override this to provide the current conversation state
Conversation get activityTipConversation;

/// Override this to provide the agent session ID for status lookups
String get activityTipAgentId;

/// Call in initState if already receiving response
void initActivityTips() {
if (activityTipConversation.state == ConversationState.receivingResponse) {
startActivityTipTimer();
}
}

/// Call in dispose to clean up both timers
void disposeActivityTips() {
_activityTipTimer?.cancel();
_factDisplayTimer?.cancel();
}

void startActivityTipTimer() {
_stopActivityTipTimerInternal();
_activityTipTimer = Timer(_activityTipThreshold, _onActivityTipThresholdReached);
}

void stopActivityTipTimer() {
_stopActivityTipTimerInternal();
_clearActivityTipWithMinimumDuration();
}

void _stopActivityTipTimerInternal() {
_activityTipTimer?.cancel();
_activityTipTimer = null;
_isGeneratingActivityTip = false;
}

/// Check if we should show activity tips.
/// Tips show when: agent is receiving response OR waiting for subagents.
bool shouldShowActivityTips() {
final isReceiving = activityTipConversation.state == ConversationState.receivingResponse;
final agentStatus = context.read(agentStatusProvider(activityTipAgentId));
final isWaitingForAgent = agentStatus == AgentStatus.waitingForAgent;
return isReceiving || isWaitingForAgent;
}

void _onActivityTipThresholdReached() {
if (!mounted || _isGeneratingActivityTip) return;

final shouldShow = shouldShowActivityTips();
if (!shouldShow) return;

_isGeneratingActivityTip = true;

// Get a random pre-generated fact directly (no Haiku needed)
final fact = FactSourceService.instance.getRandomFact();

_isGeneratingActivityTip = false;
if (!mounted) return;

final shouldShowNow = shouldShowActivityTips();

if (fact != null && shouldShowNow) {
context.read(activityTipProvider.notifier).state = fact;
// Record when fact was shown and start timer to clear after minimum duration
_factShownAt = DateTime.now();
_factDisplayTimer?.cancel();
_factDisplayTimer = Timer(_minimumFactDisplayDuration, _onFactDisplayTimerExpired);
}
}

/// Called when the minimum fact display duration has elapsed.
/// Clears the fact if conditions no longer warrant showing it.
void _onFactDisplayTimerExpired() {
_factDisplayTimer = null;
_factShownAt = null;
if (!mounted) return;
// Only clear if we're no longer in a state that should show tips
if (!shouldShowActivityTips()) {
context.read(activityTipProvider.notifier).state = null;
}
}

/// Clear activity tip, respecting minimum display duration
void _clearActivityTipWithMinimumDuration() {
if (_factShownAt != null) {
final elapsed = DateTime.now().difference(_factShownAt!);
if (elapsed >= _minimumFactDisplayDuration) {
// Minimum time elapsed, safe to clear immediately
_factDisplayTimer?.cancel();
_factDisplayTimer = null;
_factShownAt = null;
context.read(activityTipProvider.notifier).state = null;
}
// else: let _factDisplayTimer handle cleanup after minimum duration
} else {
// No fact showing, just clear
context.read(activityTipProvider.notifier).state = null;
}
}

/// Handle agent status changes from build().
/// Call this when agentStatus changes to start/stop timer accordingly.
/// [agentStatus] The current agent status
/// [conversationState] The current conversation state
void handleAgentStatusChange(AgentStatus agentStatus, ConversationState conversationState) {
// Start timer when waiting for agent (if not already running)
if (agentStatus == AgentStatus.waitingForAgent &&
_activityTipTimer == null &&
!_isGeneratingActivityTip) {
Future.microtask(() {
if (mounted) startActivityTipTimer();
});
}
// Stop timer when no longer waiting for agent and not receiving response
else if (agentStatus != AgentStatus.waitingForAgent &&
conversationState != ConversationState.receivingResponse &&
_activityTipTimer != null) {
Future.microtask(() {
if (mounted) {
_stopActivityTipTimerInternal();
_clearActivityTipWithMinimumDuration();
}
});
}
}
}
31 changes: 30 additions & 1 deletion lib/modules/agent_network/network_execution_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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';
import 'package:vide_cli/modules/agent_network/mixins/activity_tip_mixin.dart';
import '../permissions/permission_scope.dart';
import '../../components/typing_text.dart';

Expand Down Expand Up @@ -237,7 +238,7 @@ class _AgentChat extends StatefulComponent {
State<_AgentChat> createState() => _AgentChatState();
}

class _AgentChatState extends State<_AgentChat> {
class _AgentChatState extends State<_AgentChat> with ActivityTipMixin {
StreamSubscription<Conversation>? _conversationSubscription;
StreamSubscription<String?>? _queueSubscription;
Conversation _conversation = Conversation.empty();
Expand All @@ -249,6 +250,13 @@ class _AgentChatState extends State<_AgentChat> {
// Track conversation state changes for response timing
ConversationState? _lastConversationState;

// ActivityTipMixin required getters
@override
Conversation get activityTipConversation => _conversation;

@override
String get activityTipAgentId => component.client.sessionId;

@override
void initState() {
super.initState();
Expand All @@ -261,9 +269,11 @@ class _AgentChatState extends State<_AgentChat> {
if (conversation.state == ConversationState.receivingResponse &&
_lastConversationState != ConversationState.receivingResponse) {
AgentResponseTimes.startIfNeeded(component.client.sessionId);
startActivityTipTimer();
} else if (_lastConversationState == ConversationState.receivingResponse &&
conversation.state != ConversationState.receivingResponse) {
AgentResponseTimes.clear(component.client.sessionId);
stopActivityTipTimer();
}

_lastConversationState = conversation.state;
Expand All @@ -288,6 +298,9 @@ class _AgentChatState extends State<_AgentChat> {
setState(() => _queuedMessage = text);
});
_queuedMessage = component.client.currentQueuedMessage;

// Initialize activity tips
initActivityTips();
}

void _syncTokenStats(Conversation conversation) {
Expand All @@ -308,6 +321,7 @@ class _AgentChatState extends State<_AgentChat> {
void dispose() {
_conversationSubscription?.cancel();
_queueSubscription?.cancel();
disposeActivityTips();
super.dispose();
}

Expand Down Expand Up @@ -570,6 +584,13 @@ class _AgentChatState extends State<_AgentChat> {
// Get dynamic loading words from provider
final dynamicLoadingWords = context.watch(loadingWordsProvider);

// Watch activity tip and agent status for activity tips feature
final activityTip = context.watch(activityTipProvider);
final agentStatus = context.watch(agentStatusProvider(component.client.sessionId));

// Handle agent status changes for activity tip timer
handleAgentStatusChange(agentStatus, _conversation.state);

return Focusable(
onKeyEvent: _handleKeyEvent,
focused: true,
Expand Down Expand Up @@ -637,6 +658,7 @@ class _AgentChatState extends State<_AgentChat> {
)
else
Text(' '), // Reserve 1 line when loading indicator is hidden

// Show quit warning if active
if (component.showQuitWarning)
Text(
Expand Down Expand Up @@ -766,6 +788,13 @@ class _AgentChatState extends State<_AgentChat> {

// Context usage bar with compact button
_buildContextUsageSection(theme),

// Show activity tip below prompt (shown for minimum duration even after agent finishes)
if (activityTip != null)
Text(
activityTip,
style: TextStyle(color: Colors.green.withOpacity(0.7)),
),
],
),
],
Expand Down
86 changes: 86 additions & 0 deletions lib/modules/haiku/fact_source_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:io' show HttpClient;
import 'dart:math';

/// Service that fetches pre-generated facts from a curated pipeline.
/// Facts are fetched once at startup and randomly selected at runtime.
class FactSourceService {
// Singleton pattern
static final FactSourceService instance = FactSourceService._();
FactSourceService._();

static const _factsUrl =
'https://storage.googleapis.com/atelier-cms.firebasestorage.app/facts/latest.json';

// List of pre-generated facts
final List<String> _facts = [];

// Track which facts have been shown to avoid repeats within a session
final Set<int> _shownIndices = {};

// Random generator
final _random = Random();

// Whether initial fetch has completed
bool _initialized = false;

/// Whether the service has been initialized
bool get initialized => _initialized;

/// Initialize by fetching facts from the curated pipeline.
Future<void> initialize() async {
if (_initialized) return;

try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);

final request = await client.getUrl(Uri.parse(_factsUrl));
final response = await request.close();

if (response.statusCode == 200) {
final body = await response.transform(utf8.decoder).join();
final data = jsonDecode(body) as Map<String, dynamic>;

// Extract facts from the JSON
final factsList = data['facts'] as List<dynamic>? ?? [];
for (final fact in factsList) {
final text = fact['text'] as String?;
if (text != null && text.isNotEmpty) {
_facts.add(text);
}
}
}
} catch (e) {
// Graceful degradation - facts list stays empty
}

_initialized = true;
}

/// Get a random fact. Returns null if no facts available.
/// Tracks shown facts to avoid repeats within a session.
String? getRandomFact() {
if (!_initialized || _facts.isEmpty) return null;

// Reset shown indices if we've shown all facts
if (_shownIndices.length >= _facts.length) {
_shownIndices.clear();
}

// Find an unshown fact
int index;
do {
index = _random.nextInt(_facts.length);
} while (_shownIndices.contains(index) && _shownIndices.length < _facts.length);

_shownIndices.add(index);
return _facts[index];
}

/// Get next fact context (for compatibility with existing code)
/// Now just returns a random fact directly instead of source material.
String? getNextFactContext() {
return getRandomFact();
}
}