Add new features and update configurations for improved functionality

This commit is contained in:
ImBenji
2026-04-11 12:34:00 +01:00
parent fa4415553d
commit 0b6b604c56
125 changed files with 14119 additions and 1664 deletions
+10 -1
View File
@@ -6,6 +6,7 @@ import "src/project_store.dart";
import "ui/app.dart";
import "ui/providers/chat_provider.dart";
import "ui/providers/cost_provider.dart";
import "ui/providers/home_coordinator.dart";
import "ui/providers/projects_provider.dart";
import "ui/providers/session_provider.dart";
import "ui/providers/settings_provider.dart";
@@ -29,13 +30,21 @@ void main() async {
create: (_) => CostProvider(),
),
ChangeNotifierProvider(
create: (_) => SessionProvider(),
create: (_) => SessionProvider(projectStore),
),
ChangeNotifierProvider(
create: (context) => ChatProvider(
context.read<SettingsProvider>(),
),
),
ChangeNotifierProvider(
create: (context) => HomeCoordinator(
context.read<ProjectsProvider>(),
context.read<SessionProvider>(),
context.read<ChatProvider>(),
context.read<SettingsProvider>(),
),
),
],
child: const ClawdApp(),
),
+250
View File
@@ -0,0 +1,250 @@
// Agent execution context and shared state
// Represents the environment in which agents operate
import 'dart:convert';
import '../local_state.dart';
/// Shared context passed between agents
class AgentContext {
final String agentId;
final String parentAgentId;
final String sessionId;
final String workingDirectory;
final LocalSettings settings;
/// Conversation history visible to this agent
final List<Map<String, dynamic>> conversationHistory;
/// Shared variables/state between agents
final Map<String, dynamic> sharedState;
/// Files this agent has access to
final Set<String> accessiblePaths;
/// Tools this agent can invoke
final Set<String> allowedTools;
/// Results from sub-agents (if delegating work)
final Map<String, AgentResult> subAgentResults;
DateTime createdAt;
DateTime? completedAt;
AgentContext({
required this.agentId,
required this.parentAgentId,
required this.sessionId,
required this.workingDirectory,
required this.settings,
List<Map<String, dynamic>>? conversationHistory,
Map<String, dynamic>? sharedState,
Set<String>? accessiblePaths,
Set<String>? allowedTools,
}) : conversationHistory = conversationHistory ?? [],
sharedState = sharedState ?? {},
accessiblePaths = accessiblePaths ?? {},
allowedTools = allowedTools ?? {},
subAgentResults = {},
createdAt = DateTime.now();
/// Create a child agent context
AgentContext createChildContext({
required String childAgentId,
List<Map<String, dynamic>>? childConversationHistory,
}) {
return AgentContext(
agentId: childAgentId,
parentAgentId: agentId,
sessionId: sessionId,
workingDirectory: workingDirectory,
settings: settings,
conversationHistory: childConversationHistory ?? List.from(conversationHistory),
sharedState: Map.from(sharedState),
accessiblePaths: Set.from(accessiblePaths),
allowedTools: Set.from(allowedTools),
);
}
/// Add shared state (visible to all agents)
void setSharedVariable(String key, dynamic value) {
sharedState[key] = value;
}
/// Get shared variable
dynamic getSharedVariable(String key) => sharedState[key];
/// Check if agent can access a path
bool canAccessPath(String path) {
if (accessiblePaths.isEmpty) {
return true; // No restrictions
}
return accessiblePaths.contains(path) ||
accessiblePaths.any((p) => path.startsWith(p));
}
/// Check if agent can use a tool
bool canUseTool(String toolName) {
if (allowedTools.isEmpty) {
return true; // No restrictions
}
return allowedTools.contains(toolName);
}
/// Store sub-agent result
void recordSubAgentResult(String subAgentId, AgentResult result) {
subAgentResults[subAgentId] = result;
}
/// Get sub-agent result
AgentResult? getSubAgentResult(String subAgentId) => subAgentResults[subAgentId];
/// Mark agent as complete
void markComplete() {
completedAt = DateTime.now();
}
/// Get agent duration
Duration? getDuration() {
if (completedAt == null) {
return null;
}
return completedAt!.difference(createdAt);
}
/// Export context as JSON (for debugging/logging)
Map<String, dynamic> toJson() => {
'agent_id': agentId,
'parent_agent_id': parentAgentId,
'session_id': sessionId,
'working_directory': workingDirectory,
'conversation_history_length': conversationHistory.length,
'shared_state_keys': sharedState.keys.toList(),
'accessible_paths': accessiblePaths.toList(),
'allowed_tools': allowedTools.toList(),
'sub_agent_results': subAgentResults.length,
'created_at': createdAt.toIso8601String(),
'completed_at': completedAt?.toIso8601String(),
'duration_ms': getDuration()?.inMilliseconds,
};
}
/// Result of an agent execution
class AgentResult {
final String agentId;
final bool success;
final String output;
final String? error;
final Map<String, dynamic>? data;
final Duration? duration;
const AgentResult({
required this.agentId,
required this.success,
required this.output,
this.error,
this.data,
this.duration,
});
factory AgentResult.success({
required String agentId,
required String output,
Map<String, dynamic>? data,
Duration? duration,
}) =>
AgentResult(
agentId: agentId,
success: true,
output: output,
error: null,
data: data,
duration: duration,
);
factory AgentResult.failure({
required String agentId,
required String error,
Duration? duration,
}) =>
AgentResult(
agentId: agentId,
success: false,
output: '',
error: error,
data: null,
duration: duration,
);
Map<String, dynamic> toJson() => {
'agent_id': agentId,
'success': success,
'output': output,
'error': error,
'data': data,
'duration_ms': duration?.inMilliseconds,
};
}
/// Agent definition (what kind of agent to spawn)
class AgentDefinition {
final String type; // e.g., 'researcher', 'coder', 'reviewer', 'planner'
final String? model; // Override model for this agent
final String? systemPrompt; // Custom system prompt
final Map<String, dynamic>? config; // Agent-specific config
const AgentDefinition({
required this.type,
this.model,
this.systemPrompt,
this.config,
});
/// Get system prompt for this agent type
String getSystemPrompt() {
if (systemPrompt != null) {
return systemPrompt!;
}
switch (type.toLowerCase()) {
case 'researcher':
return '''You are a research agent. Your job is to:
1. Search for information using available tools
2. Aggregate findings from multiple sources
3. Provide a comprehensive summary of findings
Focus on accuracy and citing sources.''';
case 'coder':
return '''You are a code generation and implementation agent. Your job is to:
1. Write or modify code to solve problems
2. Test the code to ensure it works
3. Document the implementation
Focus on correctness and best practices.''';
case 'reviewer':
return '''You are a code review and analysis agent. Your job is to:
1. Review code for correctness and quality
2. Identify potential issues and improvements
3. Provide detailed feedback
Focus on constructive and actionable feedback.''';
case 'planner':
return '''You are a planning and strategy agent. Your job is to:
1. Break down complex tasks into steps
2. Identify dependencies and risks
3. Create detailed implementation plans
Focus on thoroughness and clarity.''';
case 'executor':
return '''You are an execution agent. Your job is to:
1. Execute planned steps
2. Handle errors and recover gracefully
3. Report progress and results
Focus on reliability and completeness.''';
default:
return '''You are an AI agent. Use available tools to accomplish your assigned task.
Focus on being thorough, accurate, and helpful.''';
}
}
}
+250
View File
@@ -0,0 +1,250 @@
// Agent coordination engine
// Manages multi-agent workflows and communication
import 'dart:async';
import '../local_state.dart';
import 'agent_context.dart';
import 'agent_executor.dart';
/// Orchestrates multiple agents working together
class AgentCoordinator {
static final AgentCoordinator _instance = AgentCoordinator._internal();
factory AgentCoordinator() => _instance;
AgentCoordinator._internal() : _executor = AgentExecutor();
final AgentExecutor _executor;
final Map<String, AgentWorkflow> _workflows = {};
int _workflowCounter = 1;
/// Create a new workflow
AgentWorkflow createWorkflow({
required String name,
required String sessionId,
required String workingDirectory,
required LocalSettings settings,
}) {
final workflow = AgentWorkflow(
id: 'workflow_${_workflowCounter++}',
name: name,
sessionId: sessionId,
workingDirectory: workingDirectory,
settings: settings,
coordinator: this,
executor: _executor,
);
_workflows[workflow.id] = workflow;
return workflow;
}
/// Get a workflow
AgentWorkflow? getWorkflow(String workflowId) => _workflows[workflowId];
/// List all workflows
List<AgentWorkflow> listWorkflows() => _workflows.values.toList();
}
/// Represents a workflow (sequence of agents)
class AgentWorkflow {
final String id;
final String name;
final String sessionId;
final String workingDirectory;
final LocalSettings settings;
final AgentCoordinator coordinator;
final AgentExecutor executor;
DateTime createdAt = DateTime.now();
DateTime? completedAt;
final List<WorkflowStep> steps = [];
final Map<String, dynamic> sharedState = {};
final Map<String, AgentResult> agentResults = {};
int currentStepIndex = 0;
bool _isRunning = false;
bool _cancelled = false;
AgentWorkflow({
required this.id,
required this.name,
required this.sessionId,
required this.workingDirectory,
required this.settings,
required this.coordinator,
required this.executor,
});
/// Add a step to the workflow
void addStep(
AgentDefinition agentDef,
String task, {
bool dependsOnPrevious = true,
}) {
steps.add(WorkflowStep(
index: steps.length,
definition: agentDef,
task: task,
dependsOnPrevious: dependsOnPrevious,
));
}
/// Execute the workflow
Future<WorkflowResult> execute({
required String apiKey,
required String model,
}) async {
if (_isRunning) {
throw Exception('Workflow $id is already running');
}
_isRunning = true;
final startTime = DateTime.now();
try {
for (int i = 0; i < steps.length; i++) {
if (_cancelled) {
break;
}
currentStepIndex = i;
final step = steps[i];
// Prepare task (substitute variables from previous results)
var task = step.task;
for (final result in agentResults.values) {
task = task.replaceAll(
'\${result}',
result.output,
);
}
// Create context for this agent
final context = AgentContext(
agentId: 'workflow_${id}_agent_$i',
parentAgentId: id,
sessionId: sessionId,
workingDirectory: workingDirectory,
settings: settings,
sharedState: Map.from(sharedState),
);
// Spawn agent
final agentId = await executor.spawnAgent(
definition: step.definition,
context: context,
task: task,
apiKey: apiKey,
model: model,
);
// Wait for agent to complete
final result = await executor.waitForAgent(agentId);
agentResults[agentId] = result;
// Update shared state from agent result
if (result.data != null) {
sharedState.addAll(result.data!);
}
if (!result.success) {
// Stop workflow on agent failure
break;
}
}
completedAt = DateTime.now();
return WorkflowResult(
id: id,
name: name,
success: agentResults.values.every((r) => r.success),
agentResults: agentResults,
sharedState: sharedState,
duration: completedAt!.difference(startTime),
);
} catch (e) {
completedAt = DateTime.now();
rethrow;
} finally {
_isRunning = false;
}
}
/// Cancel the workflow
void cancel() {
_cancelled = true;
}
/// Get workflow status
Map<String, dynamic> getStatus() => {
'id': id,
'name': name,
'is_running': _isRunning,
'current_step': currentStepIndex,
'total_steps': steps.length,
'completed_agents': agentResults.length,
'completed_at': completedAt?.toIso8601String(),
'duration_ms': completedAt?.difference(createdAt).inMilliseconds,
};
}
/// A single step in a workflow
class WorkflowStep {
final int index;
final AgentDefinition definition;
final String task;
final bool dependsOnPrevious;
WorkflowStep({
required this.index,
required this.definition,
required this.task,
required this.dependsOnPrevious,
});
}
/// Result of a complete workflow
class WorkflowResult {
final String id;
final String name;
final bool success;
final Map<String, AgentResult> agentResults;
final Map<String, dynamic> sharedState;
final Duration duration;
const WorkflowResult({
required this.id,
required this.name,
required this.success,
required this.agentResults,
required this.sharedState,
required this.duration,
});
/// Get combined output from all agents
String getCombinedOutput() {
final buffer = StringBuffer();
for (final entry in agentResults.entries) {
buffer.writeln('Agent: ${entry.key}');
buffer.writeln(entry.value.output);
buffer.writeln();
}
return buffer.toString();
}
/// Export as JSON
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'success': success,
'agent_results': {
for (final e in agentResults.entries) e.key: e.value.toJson(),
},
'shared_state': sharedState,
'duration_ms': duration.inMilliseconds,
};
}
+210
View File
@@ -0,0 +1,210 @@
// Agent execution engine
// Spawns and manages agent instances
import 'dart:async';
import '../api/openrouter_client.dart';
import '../chat/tool_loop_service.dart';
import '../local_state.dart';
import 'agent_context.dart';
/// Executes a single agent
class AgentExecutor {
static final AgentExecutor _instance = AgentExecutor._internal();
factory AgentExecutor() => _instance;
AgentExecutor._internal();
final Map<String, _RunningAgent> _agents = {};
int _idCounter = 1;
/// Spawn and run an agent
Future<String> spawnAgent({
required AgentDefinition definition,
required AgentContext context,
required String task,
required String apiKey,
required String model,
}) async {
final agentId = 'agent_${_idCounter++}';
final agent = _RunningAgent(
id: agentId,
definition: definition,
context: context.createChildContext(childAgentId: agentId),
task: task,
apiKey: apiKey,
model: model,
);
_agents[agentId] = agent;
// Start agent in background
agent.run().then((result) {
// Agent completed
context.recordSubAgentResult(agentId, result);
}).catchError((e) {
print('Agent $agentId failed: $e');
context.recordSubAgentResult(
agentId,
AgentResult.failure(agentId: agentId, error: e.toString()),
);
});
return agentId;
}
/// Get agent result (non-blocking)
AgentResult? getResult(String agentId) {
final agent = _agents[agentId];
if (agent == null) {
return null;
}
return agent.result;
}
/// Wait for agent completion
Future<AgentResult> waitForAgent(String agentId,
{Duration timeout = const Duration(hours: 24)}) async {
final agent = _agents[agentId];
if (agent == null) {
throw Exception('Agent $agentId not found');
}
return agent.waitForCompletion(timeout: timeout);
}
/// Get all running agents
List<Map<String, dynamic>> getAllAgents() {
return _agents.entries.map((e) {
return {
'id': e.key,
'type': e.value.definition.type,
'task': e.value.task,
'status': e.value.isComplete ? 'completed' : 'running',
'result': e.value.result?.toJson(),
};
}).toList();
}
/// Cancel an agent
Future<bool> cancelAgent(String agentId) async {
final agent = _agents[agentId];
if (agent == null) {
return false;
}
agent.cancel();
_agents.remove(agentId);
return true;
}
}
/// Represents a running agent instance
class _RunningAgent {
final String id;
final AgentDefinition definition;
final AgentContext context;
final String task;
final String apiKey;
final String model;
DateTime _startTime = DateTime.now();
DateTime? _endTime;
AgentResult? result;
bool _cancelled = false;
final Completer<AgentResult> _completer = Completer();
_RunningAgent({
required this.id,
required this.definition,
required this.context,
required this.task,
required this.apiKey,
required this.model,
});
bool get isComplete => _endTime != null;
/// Run the agent
Future<AgentResult> run() async {
try {
// Create API client for this agent
final client = OpenRouterClient(
config: OpenRouterConfig(
apiKey: apiKey,
model: model,
),
);
// Create tool loop for this agent
final toolLoop = ToolLoopService();
// Build agent-specific system prompt
final systemPrompt = '''${definition.getSystemPrompt()}
Task: $task
Context:
- Session ID: ${context.sessionId}
- Working Directory: ${context.workingDirectory}
- Parent Agent: ${context.parentAgentId}
${context.sharedState.isNotEmpty ? '- Shared State: ${context.sharedState}' : ''}
Use available tools to complete this task. Report your findings clearly.''';
// Run the tool loop for this agent
final toolResult = await toolLoop.runTurn(
client: client,
model: model,
apiKey: apiKey,
getSettings: () => context.settings,
apiMessages: context.conversationHistory,
userText: task,
workingDirectory: context.workingDirectory,
);
// Extract output
final output = toolResult.responseText;
// Create result
result = AgentResult.success(
agentId: id,
output: output,
data: {
'messages_exchanged': toolResult.apiMessages.length,
'web_searches': toolResult.webSearchRequests,
'web_fetches': toolResult.webFetchRequests,
},
duration: DateTime.now().difference(_startTime),
);
_endTime = DateTime.now();
_completer.complete(result!);
return result!;
} catch (e, st) {
result = AgentResult.failure(
agentId: id,
error: e.toString(),
duration: DateTime.now().difference(_startTime),
);
_endTime = DateTime.now();
_completer.completeError(e, st);
return result!;
}
}
/// Wait for agent to complete
Future<AgentResult> waitForCompletion({Duration timeout = const Duration(hours: 24)}) {
return _completer.future.timeout(timeout);
}
/// Cancel this agent
void cancel() {
_cancelled = true;
}
}
-361
View File
@@ -1,361 +0,0 @@
// Anthropic API client
// Ported from old_repo/services/api/client.ts
import "dart:async";
import "dart:convert";
import "dart:io";
import "../services/oauth_service.dart";
import "api_types.dart";
import "request_builder.dart";
import "response_parser.dart";
// Configuration for the Anthropic API client
class AnthropicClientConfig {
final String apiKey;
final String baseUrl;
final int maxRetries;
final String? model;
final String? source;
final bool enableLogging;
const AnthropicClientConfig({
required this.apiKey,
required this.baseUrl,
this.maxRetries = 2,
this.model,
this.source,
this.enableLogging = false,
});
}
// Main Anthropic API client
class AnthropicClient {
final AnthropicClientConfig _config;
late HttpClient _httpClient;
AnthropicClient({required AnthropicClientConfig config}) : _config = config {
_httpClient = HttpClient();
_httpClient.connectionTimeout = Duration(seconds: 600);
}
// Get API key from environment or config
String _getApiKey() {
if (_config.apiKey.isNotEmpty) {
return _config.apiKey;
}
final env = Platform.environment;
return env["ANTHROPIC_API_KEY"] ??
env["CLAUDE_API_KEY"] ??
env["CLAUDE_CODE_API_KEY"] ??
"";
}
// Get base URL from environment or config
String _getBaseUrl() {
if (_config.baseUrl.isNotEmpty) {
return _config.baseUrl;
}
final env = Platform.environment;
final override =
env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
if (override != null && override.isNotEmpty) {
return override;
}
return "https://api.anthropic.com";
}
// Build headers for API request
Map<String, String> _buildHeaders() {
final builder = HeaderBuilder();
// Add API key authentication
final apiKey = _getApiKey();
if (apiKey.isNotEmpty) {
builder.addAuthHeader(apiKey);
}
// Add custom headers from environment
builder.addCustomHeadersFromEnv();
return builder.build();
}
// Send a message to Claude
Future<ApiMessage> createMessage({
required String model,
required int maxTokens,
required List<Map<String, dynamic>> messages,
String? system,
double? temperature,
List<Map<String, dynamic>>? tools,
String? toolChoice,
}) async {
final requestBuilder = MessageRequestBuilder(
model: model,
maxTokens: maxTokens,
messages: messages,
);
if (system != null) {
requestBuilder.withSystem(system);
}
if (temperature != null) {
requestBuilder.withTemperature(temperature);
}
if (tools != null && tools.isNotEmpty) {
requestBuilder.withTools(tools);
if (toolChoice != null) {
requestBuilder.withToolChoice(toolChoice);
}
}
final request = requestBuilder.build();
return _makeRequest(
method: "POST",
endpoint: "/v1/messages",
body: request.toJson(),
).then((response) {
return ResponseParser.parseMessageResponse(response);
});
}
// List available models (API endpoint)
Future<List<String>> listModels() async {
final response = await _makeRequest(
method: "GET",
endpoint: "/v1/models",
);
// parse models from response
final models = <String>[];
if (response["data"] is List) {
for (final model in response["data"] as List) {
if (model is Map<String, dynamic> && model["id"] is String) {
models.add(model["id"] as String);
}
}
}
return models;
}
// Get a single model's details
Future<Map<String, dynamic>> getModel(String modelId) async {
return _makeRequest(
method: "GET",
endpoint: "/v1/models/$modelId",
);
}
// Count tokens for a message (beta API)
Future<int> countTokens({
required String model,
required List<Map<String, dynamic>> messages,
String? system,
}) async {
final body = <String, dynamic>{
"model": model,
"messages": messages,
};
if (system != null) {
body["system"] = system;
}
final response = await _makeRequest(
method: "POST",
endpoint: "/v1/messages/count_tokens",
body: body,
);
final count = response["input_tokens"];
return count is int ? count : 0;
}
// Internal: make HTTP request to API
Future<Map<String, dynamic>> _makeRequest({
required String method,
required String endpoint,
Map<String, dynamic>? body,
}) async {
final baseUrl = _getBaseUrl();
final url = Uri.parse("$baseUrl$endpoint");
final headers = _buildHeaders();
if (_config.enableLogging) {
_log("[API REQUEST] $method $endpoint");
}
try {
final request = await _httpClient.openUrl(method, url);
// Set headers
headers.forEach((key, value) {
request.headers.set(key, value);
});
// Add content type for JSON
request.headers.contentType = ContentType.json;
// Write body if present
if (body != null) {
request.write(jsonEncode(body));
}
final response = await request.close();
final responseBody = await response.transform(utf8.decoder).join();
if (_config.enableLogging) {
_log("[API RESPONSE] ${response.statusCode}");
}
// Check for errors
if (response.statusCode >= 400) {
_handleErrorResponse(response.statusCode, responseBody);
}
// Parse response
final decoded = jsonDecode(responseBody);
if (decoded is! Map<String, dynamic>) {
throw Exception("Invalid API response format");
}
return decoded;
} catch (e) {
if (_config.enableLogging) {
_log("[API ERROR] $e");
}
rethrow;
}
}
// Handle error responses
void _handleErrorResponse(int statusCode, String body) {
late String errorMessage;
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final error = ErrorParser.extractErrorMessage(decoded);
if (error != null) {
errorMessage = error;
} else {
errorMessage = "HTTP $statusCode";
}
} else {
errorMessage = "HTTP $statusCode";
}
} catch (_) {
errorMessage = "HTTP $statusCode";
}
if (statusCode == 401 || statusCode == 403) {
throw AuthenticationException(errorMessage);
} else if (statusCode == 429) {
throw RateLimitException(errorMessage);
} else if (statusCode == 413) {
throw RequestTooLargeException(errorMessage);
} else {
throw ApiException(errorMessage, statusCode);
}
}
// Internal logging
void _log(String message) {
// could wire this to real logging later
print("[AnthropicClient] $message");
}
// Cleanup
void close() {
_httpClient.close();
}
}
// Exception classes for API errors
class ApiException implements Exception {
final String message;
final int? statusCode;
ApiException(this.message, [this.statusCode]);
@override
String toString() => "ApiException: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}";
}
class AuthenticationException extends ApiException {
AuthenticationException(String message) : super(message, 401);
@override
String toString() => "AuthenticationException: $message";
}
class RateLimitException extends ApiException {
RateLimitException(String message) : super(message, 429);
@override
String toString() => "RateLimitException: $message";
}
class RequestTooLargeException extends ApiException {
RequestTooLargeException(String message) : super(message, 413);
@override
String toString() => "RequestTooLargeException: $message";
}
// Factory to create client from environment
class AnthropicClientFactory {
static Future<AnthropicClient> create({
String? apiKey,
String? baseUrl,
int maxRetries = 2,
String? model,
String? source,
bool enableLogging = false,
}) async {
// Try to get OAuth tokens if available
final tokens = await loadStoredTokens();
final resolvedApiKey = apiKey ?? _resolveApiKey();
if (resolvedApiKey.isEmpty && tokens == null) {
throw Exception("No API key found and no OAuth tokens available");
}
final config = AnthropicClientConfig(
apiKey: resolvedApiKey,
baseUrl: baseUrl ?? _resolveBaseUrl(),
maxRetries: maxRetries,
model: model,
source: source,
enableLogging: enableLogging,
);
return AnthropicClient(config: config);
}
static String _resolveApiKey() {
final env = Platform.environment;
return env["ANTHROPIC_API_KEY"] ??
env["CLAUDE_API_KEY"] ??
env["CLAUDE_CODE_API_KEY"] ??
"";
}
static String _resolveBaseUrl() {
final env = Platform.environment;
final override =
env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
if (override != null && override.isNotEmpty) {
return override;
}
return "https://api.anthropic.com";
}
}
+84 -1
View File
@@ -107,6 +107,10 @@ class ApiMessage {
final Map<String, dynamic>? usage;
final int? inputTokens;
final int? outputTokens;
final int? cacheCreationInputTokens;
final int? cacheReadInputTokens;
final int? webSearchRequests;
final int? webFetchRequests;
const ApiMessage({
required this.id,
@@ -118,8 +122,19 @@ class ApiMessage {
this.usage,
this.inputTokens,
this.outputTokens,
this.cacheCreationInputTokens,
this.cacheReadInputTokens,
this.webSearchRequests,
this.webFetchRequests,
});
/// Total context window size — matches Claude Code's getTokenCountFromUsage()
int get contextTokens =>
(inputTokens ?? 0) +
(cacheCreationInputTokens ?? 0) +
(cacheReadInputTokens ?? 0) +
(outputTokens ?? 0);
factory ApiMessage.fromJson(Map<String, dynamic> json) {
int? extractInputTokens() {
final usage = json["usage"] as Map<String, dynamic>?;
@@ -133,6 +148,38 @@ class ApiMessage {
(usage?["completion_tokens"] as num?)?.toInt();
}
int? extractWebSearchRequests() {
final usage = json["usage"] as Map<String, dynamic>?;
final direct = (usage?["web_search_requests"] as num?)?.toInt();
if (direct != null) {
return direct;
}
final serverToolUse = usage?["server_tool_use"];
if (serverToolUse is Map<String, dynamic>) {
return (serverToolUse["web_search_requests"] as num?)?.toInt();
}
return null;
}
int? extractWebFetchRequests() {
final usage = json["usage"] as Map<String, dynamic>?;
final direct = (usage?["web_fetch_requests"] as num?)?.toInt();
if (direct != null) {
return direct;
}
final serverToolUse = usage?["server_tool_use"];
if (serverToolUse is Map<String, dynamic>) {
return (serverToolUse["web_fetch_requests"] as num?)?.toInt();
}
return null;
}
final rawUsage = json["usage"] as Map<String, dynamic>?;
return ApiMessage(
id: json["id"] as String,
type: json["type"] as String? ?? "message",
@@ -141,9 +188,13 @@ class ApiMessage {
model: json["model"] as String,
stopReason:
json["stop_reason"] as String? ?? json["finish_reason"] as String?,
usage: json["usage"] as Map<String, dynamic>?,
usage: rawUsage,
inputTokens: extractInputTokens(),
outputTokens: extractOutputTokens(),
cacheCreationInputTokens: (rawUsage?["cache_creation_input_tokens"] as num?)?.toInt(),
cacheReadInputTokens: (rawUsage?["cache_read_input_tokens"] as num?)?.toInt(),
webSearchRequests: extractWebSearchRequests(),
webFetchRequests: extractWebFetchRequests(),
);
}
@@ -207,6 +258,36 @@ class ApiMessage {
return (usage?["completion_tokens"] as num?)?.toInt();
}
int? extractWebSearchRequests() {
final usage = json["usage"] as Map<String, dynamic>?;
final direct = (usage?["web_search_requests"] as num?)?.toInt();
if (direct != null) {
return direct;
}
final serverToolUse = usage?["server_tool_use"];
if (serverToolUse is Map<String, dynamic>) {
return (serverToolUse["web_search_requests"] as num?)?.toInt();
}
return null;
}
int? extractWebFetchRequests() {
final usage = json["usage"] as Map<String, dynamic>?;
final direct = (usage?["web_fetch_requests"] as num?)?.toInt();
if (direct != null) {
return direct;
}
final serverToolUse = usage?["server_tool_use"];
if (serverToolUse is Map<String, dynamic>) {
return (serverToolUse["web_fetch_requests"] as num?)?.toInt();
}
return null;
}
return ApiMessage(
id: json["id"] as String? ?? "",
type: "message",
@@ -219,6 +300,8 @@ class ApiMessage {
usage: json["usage"] as Map<String, dynamic>?,
inputTokens: extractInputTokens(),
outputTokens: extractOutputTokens(),
webSearchRequests: extractWebSearchRequests(),
webFetchRequests: extractWebFetchRequests(),
);
}
+294 -167
View File
@@ -4,6 +4,7 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "dart:math";
import "api_types.dart";
import "request_builder.dart";
@@ -12,12 +13,14 @@ import "response_parser.dart";
class OpenRouterConfig {
final String apiKey;
final int maxRetries;
final Duration requestTimeout;
final String? model;
final bool enableLogging;
const OpenRouterConfig({
required this.apiKey,
this.maxRetries = 2,
this.maxRetries = 10,
this.requestTimeout = const Duration(seconds: 300),
this.model,
this.enableLogging = false,
});
@@ -29,6 +32,8 @@ class OpenRouterClient {
bool _requestCancelled = false;
static const String _baseUrl = "https://openrouter.ai/api/v1";
static const int _baseRetryDelayMs = 500;
static const int _maxRetryDelayMs = 32000;
OpenRouterClient({required OpenRouterConfig config}) : _config = config {
_httpClient = HttpClient();
@@ -94,10 +99,12 @@ class OpenRouterClient {
}
}
final response = await _makeRequest(
method: "POST",
endpoint: "/chat/completions",
body: requestBody,
final response = await _withRetry(
() => _makeRequest(
method: "POST",
endpoint: "/chat/completions",
body: requestBody,
),
);
return ResponseParser.parseOpenRouterResponse(response);
@@ -144,175 +151,182 @@ class OpenRouterClient {
final url = Uri.parse("$_baseUrl/chat/completions");
final headers = _buildHeaders();
final textBuffer = StringBuffer();
final toolCalls = <int, _StreamingToolCallBuilder>{};
String responseId = "";
String responseModel = model;
String? finishReason;
Map<String, dynamic>? usage;
bool hasVisibleOutput = false;
return _withRetry(() async {
final textBuffer = StringBuffer();
final toolCalls = <int, _StreamingToolCallBuilder>{};
String responseId = "";
String responseModel = model;
String? finishReason;
Map<String, dynamic>? usage;
try {
if (_requestCancelled) {
throw const RequestCancelledException();
}
final request = await _httpClient.openUrl("POST", url);
headers.forEach((key, value) {
request.headers.set(key, value);
});
request.headers.contentType = ContentType.json;
request.write(jsonEncode(requestBody));
final response = await request.close();
if (response.statusCode >= 400) {
final responseBody = await response.transform(utf8.decoder).join();
print(
"OpenRouter API error ${response.statusCode} for /chat/completions: $responseBody",
);
_handleErrorResponse(response.statusCode, responseBody);
}
final responseStream = response
.transform(utf8.decoder)
.transform(const LineSplitter());
await for (final line in responseStream) {
try {
if (_requestCancelled) {
throw const RequestCancelledException();
}
final event = StreamingResponseParser.parseStreamLine(line);
if (StreamingResponseParser.isDone(event)) {
break;
}
if (event == null) {
continue;
}
final id = event["id"];
if (id is String && id.isNotEmpty) {
responseId = id;
}
final streamedModel = event["model"];
if (streamedModel is String && streamedModel.isNotEmpty) {
responseModel = streamedModel;
}
final rawUsage = event["usage"];
if (rawUsage is Map<String, dynamic>) {
usage = rawUsage;
}
final choices = event["choices"];
if (choices is! List || choices.isEmpty) {
continue;
}
final firstChoice = choices.first;
if (firstChoice is! Map<String, dynamic>) {
continue;
}
final rawFinishReason = firstChoice["finish_reason"];
if (rawFinishReason is String && rawFinishReason.isNotEmpty) {
finishReason = rawFinishReason == "tool_calls"
? "tool_use"
: rawFinishReason;
}
final delta = firstChoice["delta"];
if (delta is! Map<String, dynamic>) {
continue;
}
final content = delta["content"];
if (content is String && content.isNotEmpty) {
textBuffer.write(content);
onTextDelta?.call(content);
}
final toolCallDeltas = delta["tool_calls"];
if (toolCallDeltas is! List) {
continue;
}
for (final rawToolCall in toolCallDeltas) {
if (rawToolCall is! Map<String, dynamic>) {
continue;
}
final index = (rawToolCall["index"] as num?)?.toInt() ?? 0;
final builder = toolCalls.putIfAbsent(
index,
() => _StreamingToolCallBuilder(),
);
final toolCallId = rawToolCall["id"];
if (toolCallId is String && toolCallId.isNotEmpty) {
builder.id = toolCallId;
}
final rawType = rawToolCall["type"];
if (rawType is String && rawType.isNotEmpty) {
builder.type = rawType;
}
final function = rawToolCall["function"];
if (function is! Map<String, dynamic>) {
continue;
}
final name = function["name"];
if (name is String && name.isNotEmpty) {
builder.name = name;
}
final arguments = function["arguments"];
if (arguments is String && arguments.isNotEmpty) {
builder.arguments.write(arguments);
}
}
}
final contentBlocks = <Map<String, dynamic>>[];
final text = textBuffer.toString();
if (text.isNotEmpty) {
contentBlocks.add(<String, dynamic>{"type": "text", "text": text});
}
final orderedToolCalls = toolCalls.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
for (final entry in orderedToolCalls) {
final builder = entry.value;
contentBlocks.add(<String, dynamic>{
"type": "tool_use",
"id": builder.id,
"name": builder.name,
"input": builder.parsedArguments,
final request = await _httpClient.openUrl("POST", url);
headers.forEach((key, value) {
request.headers.set(key, value);
});
}
request.headers.contentType = ContentType.json;
request.write(jsonEncode(requestBody));
return ApiMessage(
id: responseId,
type: "message",
role: "assistant",
content: contentBlocks,
model: responseModel,
stopReason: finishReason,
usage: usage,
inputTokens: (usage?["prompt_tokens"] as num?)?.toInt(),
outputTokens: (usage?["completion_tokens"] as num?)?.toInt(),
);
} catch (e) {
if (_requestCancelled) {
throw const RequestCancelledException();
final response = await request.close();
if (response.statusCode >= 400) {
final responseBody = await response.transform(utf8.decoder).join();
print(
"OpenRouter API error ${response.statusCode} for /chat/completions: $responseBody",
);
_handleErrorResponse(response.statusCode, responseBody);
}
final responseStream = response
.transform(utf8.decoder)
.transform(const LineSplitter());
await for (final line in responseStream) {
if (_requestCancelled) {
throw const RequestCancelledException();
}
final event = StreamingResponseParser.parseStreamLine(line);
if (StreamingResponseParser.isDone(event)) {
break;
}
if (event == null) {
continue;
}
final id = event["id"];
if (id is String && id.isNotEmpty) {
responseId = id;
}
final streamedModel = event["model"];
if (streamedModel is String && streamedModel.isNotEmpty) {
responseModel = streamedModel;
}
final rawUsage = event["usage"];
if (rawUsage is Map<String, dynamic>) {
usage = rawUsage;
}
final choices = event["choices"];
if (choices is! List || choices.isEmpty) {
continue;
}
final firstChoice = choices.first;
if (firstChoice is! Map<String, dynamic>) {
continue;
}
final rawFinishReason = firstChoice["finish_reason"];
if (rawFinishReason is String && rawFinishReason.isNotEmpty) {
finishReason = rawFinishReason == "tool_calls"
? "tool_use"
: rawFinishReason;
}
final delta = firstChoice["delta"];
if (delta is! Map<String, dynamic>) {
continue;
}
final content = delta["content"];
if (content is String && content.isNotEmpty) {
hasVisibleOutput = true;
textBuffer.write(content);
onTextDelta?.call(content);
}
final toolCallDeltas = delta["tool_calls"];
if (toolCallDeltas is! List) {
continue;
}
for (final rawToolCall in toolCallDeltas) {
if (rawToolCall is! Map<String, dynamic>) {
continue;
}
final index = (rawToolCall["index"] as num?)?.toInt() ?? 0;
final builder = toolCalls.putIfAbsent(
index,
() => _StreamingToolCallBuilder(),
);
final toolCallId = rawToolCall["id"];
if (toolCallId is String && toolCallId.isNotEmpty) {
builder.id = toolCallId;
}
final rawType = rawToolCall["type"];
if (rawType is String && rawType.isNotEmpty) {
builder.type = rawType;
}
final function = rawToolCall["function"];
if (function is! Map<String, dynamic>) {
continue;
}
final name = function["name"];
if (name is String && name.isNotEmpty) {
builder.name = name;
}
final arguments = function["arguments"];
if (arguments is String && arguments.isNotEmpty) {
builder.arguments.write(arguments);
}
}
}
final contentBlocks = <Map<String, dynamic>>[];
final text = textBuffer.toString();
if (text.isNotEmpty) {
contentBlocks.add(<String, dynamic>{"type": "text", "text": text});
}
final orderedToolCalls = toolCalls.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
for (final entry in orderedToolCalls) {
final builder = entry.value;
contentBlocks.add(<String, dynamic>{
"type": "tool_use",
"id": builder.id,
"name": builder.name,
"input": builder.parsedArguments,
});
}
return ApiMessage(
id: responseId,
type: "message",
role: "assistant",
content: contentBlocks,
model: responseModel,
stopReason: finishReason,
usage: usage,
inputTokens: (usage?["prompt_tokens"] as num?)?.toInt(),
outputTokens: (usage?["completion_tokens"] as num?)?.toInt(),
);
} catch (e) {
if (_requestCancelled) {
throw const RequestCancelledException();
}
if (_config.enableLogging) {
_log("[API STREAM ERROR] $e");
}
if (hasVisibleOutput) {
throw StreamingRetryNotAllowedException(e);
}
rethrow;
}
if (_config.enableLogging) {
_log("[API STREAM ERROR] $e");
}
rethrow;
}
}, canRetryAfterTimeout: () => !hasVisibleOutput);
}
// List available models
@@ -380,6 +394,8 @@ class OpenRouterClient {
}
return decoded;
} on TimeoutException catch (_) {
throw ApiTimeoutException(_config.requestTimeout);
} catch (e) {
if (_requestCancelled) {
throw const RequestCancelledException();
@@ -427,6 +443,98 @@ class OpenRouterClient {
print("[OpenRouterClient] $message");
}
Future<T> _withRetry<T>(
Future<T> Function() operation, {
bool Function()? canRetryAfterTimeout,
}) async {
Object? lastError;
for (int attempt = 1; attempt <= _config.maxRetries + 1; attempt++) {
if (_requestCancelled) {
throw const RequestCancelledException();
}
try {
return await operation().timeout(_config.requestTimeout);
} catch (error, stackTrace) {
final retryableError =
error is TimeoutException &&
canRetryAfterTimeout != null &&
!canRetryAfterTimeout()
? StreamingRetryNotAllowedException(
ApiTimeoutException(_config.requestTimeout),
)
: error;
lastError = retryableError;
if (!_shouldRetry(retryableError) || attempt > _config.maxRetries) {
if (_config.enableLogging) {
_log("[API RETRY STOP] $retryableError");
}
Error.throwWithStackTrace(retryableError, stackTrace);
}
final delayMs = _getRetryDelayMs(attempt);
print(
"OpenRouter request failed (attempt $attempt/${_config.maxRetries + 1}), retrying in ${delayMs}ms: $retryableError",
);
_recreateHttpClient();
await Future<void>.delayed(Duration(milliseconds: delayMs));
}
}
throw lastError ?? Exception("OpenRouter request failed");
}
bool _shouldRetry(Object error) {
if (error is RequestCancelledException) {
return false;
}
if (error is StreamingRetryNotAllowedException) {
return false;
}
if (error is ApiTimeoutException) {
return true;
}
if (error is TimeoutException) {
return true;
}
if (error is SocketException) {
return true;
}
if (error is HttpException) {
return true;
}
if (error is ApiException) {
final statusCode = error.statusCode;
if (statusCode == null) {
return true;
}
if (statusCode == 408 || statusCode == 409 || statusCode == 429) {
return true;
}
if (statusCode >= 500) {
return true;
}
return false;
}
return false;
}
int _getRetryDelayMs(int attempt) {
final baseDelay = (_baseRetryDelayMs * (1 << (attempt - 1))).clamp(
_baseRetryDelayMs,
_maxRetryDelayMs,
);
final jitter = (Random().nextDouble() * 0.25 * baseDelay).round();
return baseDelay + jitter;
}
void _recreateHttpClient() {
_httpClient.close(force: true);
_httpClient = HttpClient();
_httpClient.connectionTimeout = Duration(seconds: 600);
}
void cancelActiveRequest() {
_requestCancelled = true;
_httpClient.close(force: true);
@@ -465,6 +573,23 @@ class RequestCancelledException implements Exception {
String toString() => "RequestCancelledException: Request cancelled by user";
}
class StreamingRetryNotAllowedException implements Exception {
const StreamingRetryNotAllowedException(this.cause);
final Object cause;
@override
String toString() => cause.toString();
}
class ApiTimeoutException extends ApiException {
ApiTimeoutException(Duration timeout)
: super("Request timed out after ${timeout.inSeconds} seconds", 408);
@override
String toString() => "ApiTimeoutException: $message";
}
class ApiException implements Exception {
final String message;
final int? statusCode;
@@ -500,7 +625,8 @@ class RequestTooLargeException extends ApiException {
class OpenRouterClientFactory {
static Future<OpenRouterClient> create({
String? apiKey,
int maxRetries = 2,
int maxRetries = 10,
Duration requestTimeout = const Duration(seconds: 300),
String? model,
bool enableLogging = false,
}) async {
@@ -513,6 +639,7 @@ class OpenRouterClientFactory {
final config = OpenRouterConfig(
apiKey: resolvedApiKey,
maxRetries: maxRetries,
requestTimeout: requestTimeout,
model: model,
enableLogging: enableLogging,
);
+81 -7
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'build_info.dart';
import 'chat/repl_handler.dart';
import 'command.dart';
import 'daemon/daemon_manager.dart';
import 'daemon/daemon_types.dart';
@@ -15,6 +16,8 @@ import 'local_state.dart';
import 'migration_assessment.dart';
import 'runtime_state.dart';
import 'services/cost_tracker.dart' as costTracker;
import 'services/analytics_service.dart';
import 'services/usage_tracker.dart';
import 'session/conversation_history.dart';
import 'session/session_store.dart';
import 'session/session_types.dart';
@@ -581,7 +584,29 @@ class _ClawdCli {
this.runtimeStateStore,
this.sessionState,
this.hookRunner,
);
) {
_toolRegistry = ToolRegistry();
_toolRegistry.setSettings(settingsStore.settings);
}
Future<void> _initializeServices() async {
try {
// Initialize analytics
final analytics = AnalyticsService();
final telemetryEnabled = settingsStore.settings.telemetry == 'true' ||
settingsStore.settings.telemetry == null; // Default to enabled
await analytics.initialize(enabled: telemetryEnabled);
// Initialize usage tracking
final usage = UsageTracker();
await usage.initialize(enabled: true);
// Log session start
await analytics.logSession(sessionState.sessionId ?? 'unknown', 'start');
} catch (e) {
// Silently fail - services are optional
}
}
final CommandCatalog catalog;
final RuntimeStateStore runtimeStateStore;
@@ -590,9 +615,12 @@ class _ClawdCli {
final HookRunner hookRunner;
// tool registry for direct tool invocations like "bash: echo hello"
final ToolRegistry _toolRegistry = ToolRegistry();
late final ToolRegistry _toolRegistry;
Future<CommandResult> run(List<String> args) async {
// Initialize services
await _initializeServices();
if (_isVersionFastPath(args)) {
stdout.writeln(BuildInfo.versionDisplay);
return const CommandResult();
@@ -661,10 +689,11 @@ class _ClawdCli {
return const CommandResult(exitCode: 64);
}
stderr.writeln(
'Free-form prompt execution is not ported yet. Start the REPL with no args or use a known command.',
// Free-form prompt: send to model via REPL handler
return await _handleFreeFormPrompt(
input: tokens.join(' '),
interactive: interactive,
);
return const CommandResult(exitCode: 64);
}
Future<CommandResult> _execute(
@@ -688,6 +717,9 @@ class _ClawdCli {
);
});
// Log command execution
await _logCommandExecution(command.name, args);
// run before-command hooks
await hookRunner.runHooksForKind(
HookKind.userPromptSubmit,
@@ -722,6 +754,15 @@ class _ClawdCli {
return result;
}
Future<void> _logCommandExecution(String commandName, List<String> args) async {
try {
final analytics = AnalyticsService();
await analytics.logCommand(commandName, args: args, sessionId: sessionState.sessionId);
} catch (e) {
// Silently fail - analytics is optional
}
}
Future<CommandResult> _executePortedCommand(
String name,
List<String> args, {
@@ -876,6 +917,39 @@ class _ClawdCli {
}
}
}
Future<CommandResult> _handleFreeFormPrompt({
required String input,
required bool interactive,
}) async {
if (!interactive) {
stderr.writeln('Free-form prompts are only supported in interactive mode (REPL).');
return const CommandResult(exitCode: 64);
}
try {
final handler = ReplHandler(
settings: settingsStore.settings,
sessionId: sessionState.sessionId ?? 'unknown',
workingDirectory: sessionState.workingDirectory,
);
stdout.writeln('');
await handler.executePrompt(
userInput: input,
streaming: true,
);
stdout.writeln('');
return const CommandResult();
} catch (e, st) {
stderr.writeln('Error: $e');
if (settingsStore.settings.privacyLevel == 'debug') {
stderr.writeln(st);
}
return const CommandResult(exitCode: 1);
}
}
}
@@ -2292,7 +2366,7 @@ Future<CommandResult> _runResume(
) async {
final query = args.join(' ').trim();
final sessions = await SessionStore.instance.listSessions();
final sessions = await SessionStore.instance.listSessionsForProject(context.workingDirectory);
if (sessions.isEmpty) {
context.writeLine("No saved sessions found.");
@@ -2329,7 +2403,7 @@ Future<CommandResult> _runResume(
// if exactly one match and it was a direct lookup - load it
if (filtered.length == 1 && query.isNotEmpty) {
final loaded = await SessionStore.instance.loadSession(filtered.first.id);
final loaded = await SessionStore.instance.loadSession(filtered.first.id, workingDirectory: context.workingDirectory);
if (loaded != null) {
_history.setSession(loaded);
context.sessionState.sessionName = loaded.name;
+57
View File
@@ -0,0 +1,57 @@
import "../api/openrouter_client.dart";
const _advisorSystemPrompt =
"You are an advisor reviewing an AI agent's work in progress. "
"You will be shown the full conversation history including tool calls and results. "
"Your job is to give concise, actionable guidance: identify mistakes, "
"suggest better approaches, flag assumptions that need verifying, or confirm "
"the agent is on the right track. Be direct and specific.";
const advisorToolDescription =
"Call the advisor model for a second opinion on your current approach. "
"Takes no parameters — your full conversation history is forwarded automatically. "
"Call BEFORE committing to a significant approach, BEFORE declaring done, or when stuck.";
class AdvisorService {
Future<String> run({
required String advisorModel,
required String apiKey,
required List<Map<String, dynamic>> conversationSoFar,
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
void Function(String toolName, String result)? onToolResult,
}) async {
onToolCall?.call("Advisor", {"model": advisorModel});
OpenRouterClient? client;
try {
client = OpenRouterClient(
config: OpenRouterConfig(apiKey: apiKey),
);
final response = await client.createMessage(
model: advisorModel,
maxTokens: 2048,
messages: conversationSoFar,
system: _advisorSystemPrompt,
);
final text = response.content
.whereType<Map<String, dynamic>>()
.where((b) => b["type"] == "text")
.map((b) => b["text"] as String? ?? "")
.join("\n")
.trim();
final result = text.isEmpty ? "Advisor returned no guidance." : text;
onToolResult?.call("Advisor", result);
return result;
} catch (e) {
final err = "Advisor call failed: $e";
onToolResult?.call("Advisor", err);
return err;
} finally {
client?.close();
}
}
}
+183
View File
@@ -0,0 +1,183 @@
// REPL handler for free-form prompts
// Bridges user input → ToolLoopService → model → tool execution
import 'dart:io';
import '../api/openrouter_client.dart';
import '../chat/tool_loop_service.dart';
import '../local_state.dart';
import '../services/cost_tracker.dart' as costTracker;
import '../utils/model_cost.dart';
class ReplHandler {
ReplHandler({
required this.settings,
required this.sessionId,
required this.workingDirectory,
});
final LocalSettings settings;
final String sessionId;
final String workingDirectory;
// Conversation history for this REPL session
final List<Map<String, dynamic>> _conversationHistory = [];
/// Execute a free-form prompt in the REPL
/// Returns the assistant's text response
Future<String> executePrompt({
required String userInput,
bool streaming = true,
}) async {
// Get API configuration
final apiKey = _resolveApiKey();
if (apiKey.isEmpty) {
return 'Error: No API key configured. Set OPENROUTER_API_KEY or USE_ANTHROPIC with ANTHROPIC_API_KEY.';
}
final model = settings.model ?? _getDefaultModel();
if (model.isEmpty) {
return 'Error: No model configured. Use /model to set one.';
}
// Create API client
final client = OpenRouterClient(
config: OpenRouterConfig(
apiKey: apiKey,
model: model,
enableLogging: false,
),
);
try {
// Create tool loop service
final toolLoop = ToolLoopService();
// Run the tool loop
final result = await toolLoop.runTurn(
client: client,
model: model,
apiKey: apiKey,
getSettings: () => settings,
apiMessages: _conversationHistory,
userText: userInput,
workingDirectory: workingDirectory,
onToolCall: (toolName, input) {
stderr.writeln('→ Calling $toolName');
},
onToolResult: (toolName, result) {
stderr.writeln('$toolName returned: ${result.substring(0, 100)}${result.length > 100 ? '...' : ''}');
},
onAssistantTextDelta: (delta) {
if (streaming) {
stdout.write(delta);
}
},
onAssistantMessageComplete: () {
if (streaming) {
stdout.writeln();
}
},
);
// Update conversation history
_conversationHistory.addAll(result.apiMessages);
// Track costs
if (result.response.inputTokens != null && result.response.outputTokens != null) {
final cost = calculateUSDCost(
model,
TokenUsage(
inputTokens: result.response.inputTokens!,
outputTokens: result.response.outputTokens!,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: result.webSearchRequests,
),
);
costTracker.addToTotalSessionCost(
model: model,
cost: cost,
inputTokens: result.response.inputTokens!,
outputTokens: result.response.outputTokens!,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: result.webSearchRequests,
webFetchRequests: result.webFetchRequests,
);
}
return result.responseText;
} catch (e) {
return 'Error: ${e.toString()}';
} finally {
client.close();
}
}
/// Get conversation history for session
List<Map<String, dynamic>> getHistory() => List.from(_conversationHistory);
/// Clear conversation history
void clearHistory() => _conversationHistory.clear();
String _resolveApiKey() {
// Check settings first if openRouterApiKey is configured
if (settings.openRouterApiKey?.isNotEmpty ?? false) {
return settings.openRouterApiKey!;
}
final env = Platform.environment;
// Try OpenRouter first (vendor-neutral preferred)
if ((env['USE_OPENROUTER'] ?? '').toLowerCase() == 'true' ||
(env['OPENROUTER_API_KEY']?.isNotEmpty ?? false)) {
return env['OPENROUTER_API_KEY'] ?? '';
}
// Try Anthropic
if ((env['USE_ANTHROPIC'] ?? '').toLowerCase() == 'true') {
return env['ANTHROPIC_API_KEY'] ?? env['CLAUDE_API_KEY'] ?? '';
}
// Default: check all sources
return env['OPENROUTER_API_KEY'] ??
env['ANTHROPIC_API_KEY'] ??
env['CLAUDE_API_KEY'] ??
env['CLAUDE_CODE_API_KEY'] ??
'';
}
String _getDefaultModel() {
// Check settings first
if (settings.model?.isNotEmpty ?? false) {
return settings.model!;
}
final env = Platform.environment;
// If USE_OPENROUTER is set, default to an OpenRouter model
if ((env['USE_OPENROUTER'] ?? '').toLowerCase() == 'true') {
return 'openrouter/auto'; // OpenRouter will pick best available
}
// If USE_ANTHROPIC is set or ANTHROPIC_API_KEY exists, use Claude
if ((env['USE_ANTHROPIC'] ?? '').toLowerCase() == 'true' ||
(env['ANTHROPIC_API_KEY']?.isNotEmpty ?? false)) {
return 'claude-opus-4-1';
}
// Check what API keys are available
if ((env['OPENROUTER_API_KEY']?.isNotEmpty ?? false)) {
return 'openrouter/auto';
}
if ((env['ANTHROPIC_API_KEY']?.isNotEmpty ?? false) ||
(env['CLAUDE_API_KEY']?.isNotEmpty ?? false)) {
return 'claude-opus-4-1';
}
return '';
}
}
+248 -19
View File
@@ -4,7 +4,15 @@ import "package:path/path.dart" as path;
import "../api/api_types.dart";
import "../api/openrouter_client.dart";
import "../hooks/hook_runner.dart";
import "../hooks/hook_types.dart";
import "../permissions/permission_manager.dart";
import "../permissions/permission_types.dart";
import "advisor_service.dart";
import "../local_state.dart";
import "../api/response_parser.dart";
import "../services/tool_telemetry_service.dart";
import "../system_prompt/claude_md_loader.dart";
import "../system_prompt/system_prompt_builder.dart";
import "../tools/tool_registry.dart";
@@ -14,12 +22,16 @@ class ToolLoopResult {
required this.responseText,
required this.response,
required this.finalResponseWasStreamed,
required this.webSearchRequests,
required this.webFetchRequests,
});
final List<Map<String, dynamic>> apiMessages;
final String responseText;
final ApiMessage response;
final bool finalResponseWasStreamed;
final int webSearchRequests;
final int webFetchRequests;
}
class ToolLoopException implements Exception {
@@ -38,20 +50,31 @@ class ToolLoopException implements Exception {
}
class ToolLoopService {
ToolLoopService() : _toolRegistry = ToolRegistry();
ToolLoopService({HookRunner? hookRunner})
: _toolRegistry = ToolRegistry(),
_toolTelemetryClient = createToolTelemetryClient(),
_hookRunner = hookRunner,
_advisorService = AdvisorService();
final ToolRegistry _toolRegistry;
final ToolTelemetryClient _toolTelemetryClient;
final HookRunner? _hookRunner;
final AdvisorService _advisorService;
Future<ToolLoopResult> runTurn({
required OpenRouterClient client,
required String model,
required String apiKey,
required LocalSettings Function() getSettings,
required List<Map<String, dynamic>> apiMessages,
required String userText,
String? workingDirectory,
String? advisorModel,
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
void Function(String toolName, String result)? onToolResult,
void Function(String delta)? onAssistantTextDelta,
void Function()? onAssistantMessageComplete,
Future<PermissionDecision> Function(String toolName, Map<String, dynamic> input)? onPermissionRequired,
}) async {
final updatedMessages = List<Map<String, dynamic>>.from(apiMessages)
..add(<String, dynamic>{"role": "user", "content": userText});
@@ -65,8 +88,8 @@ class ToolLoopService {
model: model,
maxTokens: 4096,
messages: updatedMessages,
system: _buildSystemPrompt(workingDirectory),
tools: _buildToolDefinitions(),
system: await _buildSystemPrompt(workingDirectory),
tools: _buildToolDefinitions(advisorModel: advisorModel),
toolChoice: "auto",
onTextDelta: (delta) {
streamedTextThisIteration = true;
@@ -91,17 +114,72 @@ class ToolLoopService {
: responseText,
response: lastResponse,
finalResponseWasStreamed: streamedTextThisIteration,
webSearchRequests: lastResponse.webSearchRequests ?? 0,
webFetchRequests: lastResponse.webFetchRequests ?? 0,
);
}
for (final toolUse in toolUses) {
// advisor is handled separately — not via the tool registry
if (toolUse.name == "Advisor") {
final advisorResult = await _advisorService.run(
advisorModel: advisorModel!,
apiKey: apiKey,
conversationSoFar: List<Map<String, dynamic>>.from(updatedMessages),
onToolCall: onToolCall,
onToolResult: onToolResult,
);
updatedMessages.add(<String, dynamic>{
"role": "tool",
"tool_call_id": toolUse.id,
"content": advisorResult,
});
continue;
}
final currentSettings = getSettings();
final normalizedInput = _normalizeToolInput(
toolName: toolUse.name,
input: toolUse.input,
apiKey: apiKey,
model: model,
settings: currentSettings,
workingDirectory: workingDirectory,
);
// check permissions before executing
final permManager = PermissionManager(currentSettings);
if (permManager.shouldAskForPermission(toolUse.name, normalizedInput, workingDirectory)) {
onToolCall?.call(toolUse.name, normalizedInput);
PermissionDecision decision;
if (onPermissionRequired != null) {
decision = await onPermissionRequired(toolUse.name, normalizedInput);
} else {
decision = PermissionDecision.reject;
}
if (decision == PermissionDecision.reject) {
const denied = "Permission denied by user.";
onToolResult?.call(toolUse.name, denied);
updatedMessages.add(<String, dynamic>{
"role": "tool",
"tool_call_id": toolUse.id,
"content": denied,
});
continue;
}
// allowOnce or allowAlways — fall through to execute
}
onToolCall?.call(toolUse.name, normalizedInput);
await _hookRunner?.runHooksForKind(
HookKind.preToolUse,
targetName: toolUse.name,
input: normalizedInput,
);
final toolResult = await _executeTool(
toolUse: toolUse,
normalizedInput: normalizedInput,
@@ -133,15 +211,55 @@ class ToolLoopService {
required ToolUse toolUse,
required Map<String, dynamic> normalizedInput,
}) async {
final stopwatch = Stopwatch()..start();
print(
"Executing tool ${toolUse.name} with input: ${jsonEncode(normalizedInput)}",
);
try {
final result = await _toolRegistry.execute(toolUse.name, normalizedInput);
final success = !result.startsWith("Error");
await _toolTelemetryClient.recordToolCall(
toolName: toolUse.name,
success: success,
durationMs: stopwatch.elapsedMilliseconds,
);
if (success) {
await _hookRunner?.runHooksForKind(
HookKind.postToolUse,
targetName: toolUse.name,
input: normalizedInput,
output: result,
exitCode: 0,
);
} else {
await _hookRunner?.runHooksForKind(
HookKind.postToolUseFailure,
targetName: toolUse.name,
input: normalizedInput,
output: result,
exitCode: 1,
);
}
print("Tool ${toolUse.name} completed");
return result;
} catch (error, stackTrace) {
await _toolTelemetryClient.recordToolCall(
toolName: toolUse.name,
success: false,
durationMs: stopwatch.elapsedMilliseconds,
);
await _hookRunner?.runHooksForKind(
HookKind.postToolUseFailure,
targetName: toolUse.name,
input: normalizedInput,
output: error.toString(),
exitCode: 1,
);
print("Tool ${toolUse.name} failed: $error");
print(stackTrace);
return "Error executing ${toolUse.name}: $error";
@@ -151,34 +269,53 @@ class ToolLoopService {
Map<String, dynamic> _normalizeToolInput({
required String toolName,
required Map<String, dynamic> input,
required String apiKey,
required String model,
required LocalSettings settings,
String? workingDirectory,
}) {
final normalized = Map<String, dynamic>.from(input);
final cwd = workingDirectory?.trim();
if (cwd == null || cwd.isEmpty) {
return normalized;
}
switch (toolName) {
case "Bash":
normalized["cwd"] = cwd;
if (cwd != null && cwd.isNotEmpty) {
normalized["cwd"] = cwd;
}
break;
case "Read":
case "Edit":
case "Write":
final rawPath = normalized["file_path"];
if (rawPath is String && rawPath.isNotEmpty) {
normalized["file_path"] = _resolvePath(rawPath, cwd);
if (cwd != null && cwd.isNotEmpty) {
final rawPath = normalized["file_path"];
if (rawPath is String && rawPath.isNotEmpty) {
normalized["file_path"] = _resolvePath(rawPath, cwd);
}
}
break;
case "Glob":
case "Grep":
final rawPath = normalized["path"];
if (rawPath is String && rawPath.isNotEmpty) {
normalized["path"] = _resolvePath(rawPath, cwd);
} else {
normalized["path"] = cwd;
if (cwd != null && cwd.isNotEmpty) {
final rawPath = normalized["path"];
if (rawPath is String && rawPath.isNotEmpty) {
normalized["path"] = _resolvePath(rawPath, cwd);
} else {
normalized["path"] = cwd;
}
}
break;
case "WebSearch":
case "WebFetch":
normalized["_api_key"] = apiKey;
normalized["_model"] = model;
normalized["_permission_mode"] = settings.permissionMode;
normalized["_allow_rules"] = settings.alwaysAllowRules;
normalized["_ask_rules"] = settings.alwaysAskRules;
normalized["_deny_rules"] = settings.alwaysDenyRules;
break;
case "ExecuteTask":
if (cwd != null && cwd.isNotEmpty) {
normalized["working_directory"] = cwd;
}
break;
}
@@ -228,8 +365,19 @@ class ToolLoopService {
return message;
}
List<Map<String, dynamic>> _buildToolDefinitions() {
List<Map<String, dynamic>> _buildToolDefinitions({String? advisorModel}) {
return <Map<String, dynamic>>[
if (advisorModel != null && advisorModel.isNotEmpty)
_functionTool(
name: "Advisor",
description:
"Call the advisor model for a second opinion on your current approach. "
"Takes no parameters — your full conversation history is forwarded automatically. "
"Call BEFORE committing to a significant approach, BEFORE declaring done, or when stuck.",
properties: <String, dynamic>{},
required: const <String>[],
),
_functionTool(
name: "Bash",
description:
@@ -284,6 +432,44 @@ class ToolLoopService {
},
required: const <String>["pattern"],
),
_functionTool(
name: "WebSearch",
description:
"Search the web for current information. Supports optional allowed_domains or blocked_domains filters and returns a cited summary.",
properties: <String, dynamic>{
"query": <String, dynamic>{
"type": "string",
"description": "The web search query to run.",
},
"allowed_domains": <String, dynamic>{
"type": "array",
"items": <String, dynamic>{"type": "string"},
"description": "Optional list of domains to include.",
},
"blocked_domains": <String, dynamic>{
"type": "array",
"items": <String, dynamic>{"type": "string"},
"description": "Optional list of domains to exclude.",
},
},
required: const <String>["query"],
),
_functionTool(
name: "WebFetch",
description:
"Fetch content from a URL, extract readable text, and answer a prompt about that page.",
properties: <String, dynamic>{
"url": <String, dynamic>{
"type": "string",
"description": "The fully formed URL to fetch.",
},
"prompt": <String, dynamic>{
"type": "string",
"description": "What information to extract from the fetched page.",
},
},
required: const <String>["url", "prompt"],
),
_functionTool(
name: "Read",
description: "Read a file from the project with line numbers.",
@@ -341,6 +527,40 @@ class ToolLoopService {
},
required: const <String>["file_path", "content"],
),
_functionTool(
name: "ExecuteTask",
description:
"Execute a task as a background process with real process management.",
properties: <String, dynamic>{
"action": <String, dynamic>{
"type": "string",
"enum": <String>["execute", "status", "result", "cancel", "list"],
"description": "Action to perform.",
},
"task_id": <String, dynamic>{
"type": "string",
"description": "Task identifier (required for execute).",
},
"command": <String, dynamic>{
"type": "string",
"description": "Command to execute (required for execute).",
},
"arguments": <String, dynamic>{
"type": "array",
"items": <String, dynamic>{"type": "string"},
"description": "Command arguments.",
},
"process_id": <String, dynamic>{
"type": "string",
"description": "Process identifier (for status/result/cancel).",
},
"force": <String, dynamic>{
"type": "boolean",
"description": "Force kill when canceling.",
},
},
required: const <String>["action"],
),
];
}
@@ -365,7 +585,7 @@ class ToolLoopService {
};
}
String _buildSystemPrompt(String? workingDirectory) {
Future<String> _buildSystemPrompt(String? workingDirectory) async {
final cwd = workingDirectory?.trim();
final appendPrompt = [
if (cwd == null || cwd.isEmpty)
@@ -373,13 +593,22 @@ class ToolLoopService {
else
"The active working directory is: $cwd",
"You have access to tools for shell commands, file globbing, grep search, file reads, exact edits, and file writes.",
"You also have a WebSearch tool for up-to-date external information; when you use it, include a Sources section with markdown links in your final answer.",
"You also have a WebFetch tool for reading a specific public URL and answering questions about that page.",
"If MCP-provided web search or web fetch tools are available in your tool list, prefer them over the built-in WebSearch and WebFetch tools.",
"When the user asks about files, code, project structure, configuration, or repository contents, use the tools instead of guessing.",
"If the user asks you to inspect the project structure, start by using Glob or Bash to inspect the filesystem.",
"Do not claim you cannot access the project when tools are available.",
"Keep answers concise and grounded in tool results.",
].join("\n");
return buildDefaultSystemPrompt(appendSystemPrompt: appendPrompt);
final memoryFiles = await getMemoryFiles(workingDirectory);
final claudeMd = getClaudeMds(memoryFiles);
return buildDefaultSystemPrompt(
appendSystemPrompt: appendPrompt,
claudeMd: claudeMd.isEmpty ? null : claudeMd,
);
}
String _buildEmptyAssistantFallback(ApiMessage response) {
+54
View File
@@ -0,0 +1,54 @@
// Constants for Claude Code
// Vendor-neutral abstractions for remote services - ACTUALLY INTEGRATED
/// Base endpoint for hosted services
/// Replace anthropic.com specific endpoints with this vendor-neutral constant
const String kHostEndpoint = String.fromEnvironment(
'CLAWED_HOST_ENDPOINT',
defaultValue: '', // Empty means use local fallback
);
/// Environment variable for overriding host endpoint
const String kHostEndpointEnvVar = 'CLAWED_HOST_ENDPOINT';
/// Check if remote services are available
bool areRemoteServicesAvailable() {
return kHostEndpoint.isNotEmpty;
}
/// Get configured host endpoint with validation
String getHostEndpoint() {
return kHostEndpoint;
}
/// Check if a feature should use remote service or local fallback
bool shouldUseRemoteService(String feature) {
if (!areRemoteServicesAvailable()) return false;
// For now, all features can use remote if endpoint is configured
return true;
}
/// API paths for remote services (relative to kHostEndpoint)
class ApiPaths {
static const String sessions = '/api/v1/sessions';
static const String analytics = '/api/v1/analytics';
static const String usage = '/api/v1/usage';
static const String config = '/api/v1/config';
static const String auth = '/api/v1/auth';
/// Get session URL for a given session ID
static String sessionUrl(String sessionId) => '$sessions/$sessionId';
/// Get session worker URL for a given session ID
static String sessionWorkerUrl(String sessionId) => '$sessions/$sessionId/worker';
}
/// Get full URL for a remote service
String getRemoteServiceUrl(String path) {
final endpoint = getHostEndpoint();
if (endpoint.isEmpty) {
throw Exception('Remote services not configured');
}
return '$endpoint$path';
}
+46
View File
@@ -0,0 +1,46 @@
// Configuration constants for Claude Code Dart migration
// Vendor-neutral configuration with fallbacks
/// Environment variable configuration keys
class Config {
/// Whether to use custom endpoints
static const bool useCustomEndpoints = false;
/// API endpoint from environment (vendor-neutral)
static const String? apiEndpoint = null;
/// Default endpoints when not overridden
static const String defaultApiEndpoint = String.fromEnvironment(
'CLAWED_HOST_ENDPOINT',
defaultValue: '',
);
/// Model Configuration (vendor-neutral)
static const String defaultMainModel = String.fromEnvironment(
'CLAWED_DEFAULT_MODEL',
defaultValue: '',
);
/// Model aliases mapping (configurable)
static final Map<String, String> modelAliases = {
// Can be populated from config file
};
/// Model context window sizes (configurable)
static final Map<String, int> modelContextWindows = {
// Can be populated from config file
};
/// Home directory for configuration
static String get homeDirectory => '~/.clawd_code';
/// Configuration file paths
static String get configPath => '${homeDirectory}/config';
static String get settingsFilePath => '${configPath}/settings.json';
/// Parse boolean environment variable
static bool parseBoolEnv(String value) {
final lowerValue = value.toLowerCase();
return lowerValue == 'true' || lowerValue == '1' || lowerValue == 'yes';
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ String getCoordinatorSystemPrompt() {
? "Workers have access to Bash, Read, and Edit tools, plus MCP tools from configured MCP servers."
: "Workers have access to standard tools, MCP tools from configured MCP servers, and project skills via the Skill tool. Delegate skill invocations (e.g. /commit, /verify) to workers.";
return """You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.
return """You are The Agency, an AI assistant that orchestrates software engineering tasks across multiple workers.
## 1. Your Role
+37 -1
View File
@@ -180,6 +180,34 @@ class LocalSettings {
);
}
// Merges this (base) with an override layer.
// For nullable fields: override wins only if non-null.
// For list fields: override wins if non-empty.
// For non-nullable fields with defaults: override wins if different from its default.
LocalSettings mergeWith(LocalSettings? override) {
if (override == null) return this;
return LocalSettings(
advisorModel: override.advisorModel ?? advisorModel,
alwaysAllowRules: override.alwaysAllowRules.isNotEmpty ? override.alwaysAllowRules : alwaysAllowRules,
alwaysAskRules: override.alwaysAskRules.isNotEmpty ? override.alwaysAskRules : alwaysAskRules,
alwaysDenyRules: override.alwaysDenyRules.isNotEmpty ? override.alwaysDenyRules : alwaysDenyRules,
editorMode: override.editorMode != 'normal' ? override.editorMode : editorMode,
effortLevel: override.effortLevel ?? effortLevel,
fastMode: override.fastMode ? true : fastMode,
hooks: override.hooks ?? hooks,
mcpServers: override.mcpServers ?? mcpServers,
model: override.model ?? model,
openRouterApiKey: override.openRouterApiKey ?? openRouterApiKey,
outputStyle: override.outputStyle ?? outputStyle,
permissionMode: override.permissionMode != 'default' ? override.permissionMode : permissionMode,
privacyLevel: override.privacyLevel ?? privacyLevel,
statusLinePrompt: override.statusLinePrompt ?? statusLinePrompt,
telemetry: override.telemetry ?? telemetry,
theme: override.theme != 'dark' ? override.theme : theme,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'advisorModel': advisorModel,
@@ -206,7 +234,8 @@ class LocalSettings {
class SessionState {
SessionState({required this.workingDirectory, String? effortValue})
: effortValue = effortValue,
startedAt = DateTime.now().toUtc();
startedAt = DateTime.now().toUtc(),
sessionId = _generateSessionId();
String? effortValue;
int commandsExecuted = 0;
@@ -229,6 +258,13 @@ class SessionState {
final DateTime startedAt;
final String workingDirectory;
final String sessionId;
static String _generateSessionId() {
final now = DateTime.now();
final rnd = (now.millisecondsSinceEpoch % 1000000).toString().padLeft(6, '0');
return 'sess_${now.millisecondsSinceEpoch}_$rnd';
}
String get planFilePath {
return joinPath(getPlansDirectoryPath(), 'active-plan.md');
+279
View File
@@ -0,0 +1,279 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import '../local_state.dart';
/// Manages tool permissions and access control
class PermissionManager {
final LocalSettings settings;
PermissionManager(this.settings);
/// Check if a tool is allowed to run
bool isToolAllowed(String toolName, [String? toolArgs]) {
// Check bypass mode
if (settings.permissionMode == 'bypassPermissions') {
return true;
}
// Check explicit deny rules
for (final rule in settings.alwaysDenyRules) {
if (_matchesRule(toolName, toolArgs, rule)) {
return false;
}
}
// Check explicit allow rules
for (final rule in settings.alwaysAllowRules) {
if (_matchesRule(toolName, toolArgs, rule)) {
return true;
}
}
// Check ask rules (requires user confirmation)
for (final rule in settings.alwaysAskRules) {
if (_matchesRule(toolName, toolArgs, rule)) {
// In interactive mode, we would ask user
// For now, default to false in non-interactive contexts
return false;
}
}
// Default behavior based on permission mode
switch (settings.permissionMode) {
case 'acceptEdits':
// Accept file edits but ask for other tools
return toolName.toLowerCase() == 'fileedit' ||
toolName.toLowerCase() == 'filewrite';
case 'dontAsk':
// Don't ask, just run (legacy default)
return true;
case 'plan':
// Only allow during plan execution
return true; // Would check plan context in real implementation
case 'bubble':
// Bubble up to parent process for decision
return false; // Default deny, parent must approve
case 'auto':
// Auto-detect based on tool safety
return _isToolConsideredSafe(toolName);
case 'default':
default:
// Default behavior: allow safe tools, ask for others
return _isToolConsideredSafe(toolName);
}
}
/// Check if a tool should trigger a user confirmation.
/// Pass the full normalized input and the working directory so path-based
/// tools (Read, Glob, Grep) can auto-allow paths inside the cwd.
bool shouldAskForPermission(
String toolName,
Map<String, dynamic> input,
String? workingDirectory,
) {
// alwaysAsk rules always win
for (final rule in settings.alwaysAskRules) {
if (_matchesRule(toolName, null, rule)) return true;
}
// explicit deny → dont ask (tool will be denied, not prompted)
if (settings.alwaysDenyRules.any((r) => _matchesRule(toolName, null, r))) {
return false;
}
// explicit allow → no prompt needed
if (settings.alwaysAllowRules.any((r) => _matchesRule(toolName, null, r))) {
return false;
}
switch (settings.permissionMode) {
case 'bypassPermissions':
return false;
case 'dontAsk':
return false;
case 'bubble':
return true;
case 'acceptEdits':
// edit/write tools are auto-accepted, everything else prompts
final n = toolName.toLowerCase();
return !(n == 'edit' || n == 'write' || n == 'fileedit' || n == 'filewrite');
case 'default':
case 'auto':
default:
return _defaultShouldAsk(toolName, input, workingDirectory);
}
}
bool _defaultShouldAsk(
String toolName,
Map<String, dynamic> input,
String? workingDirectory,
) {
final cwd = workingDirectory?.trim();
switch (toolName) {
// read-only filesystem tools: allow if path is inside cwd
case 'Read':
case 'Glob':
case 'Grep':
if (cwd == null || cwd.isEmpty) return true;
final pathArg = (input['file_path'] ?? input['path']) as String?;
if (pathArg == null || pathArg.isEmpty) return false;
final abs = p.isAbsolute(pathArg) ? pathArg : p.join(cwd, pathArg);
final norm = p.normalize(abs);
return !norm.startsWith(p.normalize(cwd));
// write tools always ask
case 'Edit':
case 'Write':
case 'Bash':
return true;
// network tools always ask
case 'WebSearch':
case 'WebFetch':
return true;
// everything else asks by default
default:
return true;
}
}
/// Get permission decision for a tool (allowed, denied, or ask)
String getPermissionDecision(String toolName, Map<String, dynamic> input, String? workingDirectory) {
if (!isToolAllowed(toolName)) {
return 'denied';
}
if (shouldAskForPermission(toolName, input, workingDirectory)) {
return 'ask';
}
return 'allowed';
}
/// Parse a permission rule
Map<String, dynamic> parsePermissionRule(String rule) {
final result = <String, dynamic>{
'tool': '',
'pattern': '',
'args': '',
};
// Check for tool(args) pattern
final match = RegExp(r'^(\w+)\((.*)\)$').firstMatch(rule);
if (match != null) {
result['tool'] = match.group(1)?.toLowerCase() ?? '';
result['args'] = match.group(2) ?? '';
result['pattern'] = rule;
} else {
// Just tool name
result['tool'] = rule.toLowerCase();
result['pattern'] = rule;
}
return result;
}
/// Check if a tool matches a permission rule
bool _matchesRule(String toolName, String? toolArgs, String rule) {
final parsed = parsePermissionRule(rule);
final ruleTool = parsed['tool'] as String;
// Check tool name match
if (toolName.toLowerCase() != ruleTool) {
return false;
}
// Check args if specified in rule
final ruleArgs = parsed['args'] as String;
if (ruleArgs.isNotEmpty && toolArgs != null) {
// Simple substring matching for args
return toolArgs.contains(ruleArgs);
}
return true;
}
/// Determine if a tool is considered "safe" for auto-allow
bool _isToolConsideredSafe(String toolName) {
const safeTools = {
'bash': false, // Can run arbitrary commands
'fileedit': false, // Modifies files
'filewrite': false, // Writes files
'websearch': false, // Makes network requests
'webfetch': false, // Makes network requests
'agent': false, // Can spawn other agents
'task': false, // Can run background tasks
'skill': false, // Can execute arbitrary skills
'mcp': false, // Can connect to external servers
'glob': true, // Just lists files
'grep': true, // Just searches files
'fileread': true, // Just reads files
'simpleagent': false, // Agent operations
};
return safeTools[toolName.toLowerCase()] ?? false;
}
/// Get user confirmation for a tool (simulated for now)
Future<bool> getUserConfirmation(
String toolName, String? toolArgs, String prompt) async {
// In a real implementation, this would show an interactive prompt
// For now, simulate based on environment variable
final autoConfirm = Platform.environment['CLAWED_AUTO_CONFIRM'] == 'true';
if (autoConfirm) {
print('Auto-confirming: $toolName $toolArgs');
return true;
}
print('Permission required: $prompt');
print('Tool: $toolName${toolArgs != null ? ' with args: $toolArgs' : ''}');
print('Run with CLAWED_AUTO_CONFIRM=true to auto-confirm.');
// Default deny in non-interactive mode
return false;
}
/// Format permission rules for display
String formatPermissionRules() {
final buffer = StringBuffer();
buffer.writeln('Permission Mode: ${settings.permissionMode}');
buffer.writeln();
if (settings.alwaysAllowRules.isNotEmpty) {
buffer.writeln('Always Allow:');
for (final rule in settings.alwaysAllowRules) {
buffer.writeln('$rule');
}
buffer.writeln();
}
if (settings.alwaysDenyRules.isNotEmpty) {
buffer.writeln('Always Deny:');
for (final rule in settings.alwaysDenyRules) {
buffer.writeln('$rule');
}
buffer.writeln();
}
if (settings.alwaysAskRules.isNotEmpty) {
buffer.writeln('Always Ask:');
for (final rule in settings.alwaysAskRules) {
buffer.writeln('$rule');
}
buffer.writeln();
}
if (settings.alwaysAllowRules.isEmpty &&
settings.alwaysDenyRules.isEmpty &&
settings.alwaysAskRules.isEmpty) {
buffer.writeln('No specific permission rules configured.');
buffer.writeln('Using mode: ${settings.permissionMode}');
}
return buffer.toString();
}
}
+20
View File
@@ -0,0 +1,20 @@
import "dart:async";
enum PermissionDecision { allowOnce, allowAlways, reject }
class PendingPermission {
PendingPermission({required this.toolName, required this.input})
: _completer = Completer<PermissionDecision>();
final String toolName;
final Map<String, dynamic> input;
final Completer<PermissionDecision> _completer;
Future<PermissionDecision> get future => _completer.future;
void resolve(PermissionDecision decision) {
if (!_completer.isCompleted) {
_completer.complete(decision);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import "dart:convert";
import "dart:io";
import "package:path/path.dart" as p;
import "local_state.dart";
import "session/session_store.dart";
const _encoder = JsonEncoder.withIndent(" ");
String getProjectSettingsPath(String workingDirectory) {
return p.join(getProjectAgencyDir(workingDirectory), "settings.json");
}
class ProjectSettingsStore {
ProjectSettingsStore._();
static final ProjectSettingsStore instance = ProjectSettingsStore._();
Future<LocalSettings?> load(String workingDirectory) async {
final file = File(getProjectSettingsPath(workingDirectory));
if (!await file.exists()) return null;
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return LocalSettings.fromJson(decoded);
}
} catch (_) {}
return null;
}
Future<void> save(String workingDirectory, LocalSettings settings) async {
final dir = Directory(getProjectAgencyDir(workingDirectory));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final file = File(getProjectSettingsPath(workingDirectory));
await file.writeAsString("${_encoder.convert(settings.toJson())}\n");
}
}
+292
View File
@@ -0,0 +1,292 @@
import 'dart:convert';
import 'dart:io';
import '../constants.dart';
/// Analytics and telemetry service
/// Supports both local logging and remote analytics with vendor-neutral abstraction
class AnalyticsService {
static final AnalyticsService _instance = AnalyticsService._internal();
factory AnalyticsService() => _instance;
AnalyticsService._internal();
bool _enabled = true;
bool _initialized = false;
final List<Map<String, dynamic>> _eventBuffer = [];
final String _logPath = _getLogFilePath();
/// Initialize analytics service
Future<void> initialize({bool enabled = true}) async {
if (_initialized) return;
_enabled = enabled && areRemoteServicesAvailable();
_initialized = true;
// Load existing log if any
await _loadEventBuffer();
// Log initialization event
await _logEvent('analytics_initialized', {
'timestamp': DateTime.now().toUtc().toIso8601String(),
'enabled': _enabled,
'remote_services_available': areRemoteServicesAvailable(),
});
// Flush buffer if we have pending events
if (_eventBuffer.isNotEmpty) {
await _flushEvents();
}
}
/// Log an event
Future<void> logEvent(String eventName,
{Map<String, dynamic>? properties,
Map<String, dynamic>? metrics}) async {
if (!_initialized) {
await initialize();
}
if (!_enabled) return;
final event = <String, dynamic>{
'event': eventName,
'timestamp': DateTime.now().toUtc().toIso8601String(),
if (properties != null && properties.isNotEmpty) 'properties': properties,
if (metrics != null && metrics.isNotEmpty) 'metrics': metrics,
};
// Add to buffer and try to send
_eventBuffer.add(event);
await _saveEventBuffer();
// Try to send immediately, but don't block
_flushEventsInBackground();
}
/// Log command execution
Future<void> logCommand(String commandName,
{List<String>? args,
int? exitCode,
int? durationMs,
String? sessionId}) async {
await logEvent('command_executed', properties: {
'command': commandName,
if (args != null && args.isNotEmpty) 'args_count': args.length,
if (exitCode != null) 'exit_code': exitCode,
if (durationMs != null) 'duration_ms': durationMs,
if (sessionId != null) 'session_id': sessionId,
});
}
/// Log tool execution
Future<void> logTool(String toolName,
{Map<String, dynamic>? input,
String? result,
int? durationMs,
bool? allowed}) async {
await logEvent('tool_executed', properties: {
'tool': toolName,
if (input != null && input.isNotEmpty) 'input_keys': input.keys.toList(),
if (result != null) 'result_length': result.length,
if (durationMs != null) 'duration_ms': durationMs,
if (allowed != null) 'allowed': allowed,
});
}
/// Log model usage
Future<void> logModelUsage(String modelName,
{int? inputTokens,
int? outputTokens,
double? costUsd,
int? durationMs}) async {
await logEvent('model_usage', properties: {
'model': modelName,
if (inputTokens != null) 'input_tokens': inputTokens,
if (outputTokens != null) 'output_tokens': outputTokens,
if (costUsd != null) 'cost_usd': costUsd,
if (durationMs != null) 'duration_ms': durationMs,
});
}
/// Log error
Future<void> logError(String errorType,
{String? message,
StackTrace? stackTrace,
String? context}) async {
await logEvent('error', properties: {
'type': errorType,
if (message != null) 'message': message,
if (context != null) 'context': context,
if (stackTrace != null) 'stack_trace': stackTrace.toString(),
});
}
/// Log session start/end
Future<void> logSession(String sessionId, String action,
{int? messageCount,
int? durationMs,
double? totalCost}) async {
await logEvent('session_$action', properties: {
'session_id': sessionId,
if (messageCount != null) 'message_count': messageCount,
if (durationMs != null) 'duration_ms': durationMs,
if (totalCost != null) 'total_cost': totalCost,
});
}
/// Flush events to remote service
Future<void> flush() async {
await _flushEvents();
}
/// Get analytics status
Map<String, dynamic> getStatus() {
return {
'enabled': _enabled,
'initialized': _initialized,
'buffer_size': _eventBuffer.length,
'log_path': _logPath,
'remote_services_available': areRemoteServicesAvailable(),
};
}
/// Enable or disable analytics
void setEnabled(bool enabled) {
_enabled = enabled;
}
// Private methods
Future<void> _flushEvents() async {
if (_eventBuffer.isEmpty) return;
final eventsToSend = List<Map<String, dynamic>>.from(_eventBuffer);
_eventBuffer.clear();
// Try to send to remote service
bool remoteSuccess = false;
if (shouldUseRemoteService('analytics')) {
try {
remoteSuccess = await _sendToRemoteService(eventsToSend);
} catch (e) {
// Log error but continue with local fallback
await _logLocal('Failed to send analytics to remote: $e');
}
}
// Always log locally as backup
await _logLocal('Analytics events (remote: $remoteSuccess):');
for (final event in eventsToSend) {
await _logLocal(' ${jsonEncode(event)}');
}
await _saveEventBuffer();
}
void _flushEventsInBackground() {
Future.microtask(() async {
try {
await _flushEvents();
} catch (e) {
// Silently fail for background flushes
}
});
}
Future<bool> _sendToRemoteService(List<Map<String, dynamic>> events) async {
if (!shouldUseRemoteService('analytics')) {
return false;
}
try {
final url = getRemoteServiceUrl('${ApiPaths.analytics}/batch');
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({
'events': events,
'timestamp': DateTime.now().toUtc().toIso8601String(),
'client': 'clawd_code',
'version': '1.0.0',
}));
final response = await request.close();
await response.drain();
return response.statusCode >= 200 && response.statusCode < 300;
} catch (e) {
return false;
}
}
Future<void> _logEvent(String eventName, Map<String, dynamic> data) async {
final logFile = File(_logPath);
final logLine = jsonEncode({
'event': eventName,
...data,
'logged_at': DateTime.now().toUtc().toIso8601String(),
});
try {
await logFile.parent.create(recursive: true);
await logFile.writeAsString('$logLine\n', mode: FileMode.append);
} catch (e) {
// Silently fail if we can't write to log
}
}
Future<void> _logLocal(String message) async {
final logFile = File(_logPath);
final logLine =
'[${DateTime.now().toLocal()}] $message\n';
try {
await logFile.parent.create(recursive: true);
await logFile.writeAsString(logLine, mode: FileMode.append);
} catch (e) {
// Silently fail if we can't write to log
}
}
Future<void> _loadEventBuffer() async {
final logFile = File(_logPath);
if (!await logFile.exists()) return;
try {
final lines = await logFile.readAsLines();
for (final line in lines) {
if (line.trim().isEmpty) continue;
try {
final event = jsonDecode(line) as Map<String, dynamic>;
_eventBuffer.add(event);
} catch (e) {
// Skip invalid JSON lines
}
}
} catch (e) {
// Reset buffer if we can't read the file
_eventBuffer.clear();
}
}
Future<void> _saveEventBuffer() async {
final logFile = File(_logPath);
try {
await logFile.parent.create(recursive: true);
final lines = _eventBuffer.map(jsonEncode).join('\n');
await logFile.writeAsString('$lines\n');
} catch (e) {
// Silently fail if we can't write to log
}
}
static String _getLogFilePath() {
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
Directory.current.path;
return Platform.isWindows
? '$home\\.clawd_code\\analytics.log'
: '$home/.clawd_code/analytics.log';
}
}
+22 -7
View File
@@ -1,19 +1,22 @@
// Anthropic API client stub
// Ported from old_repo/services/api/client.ts
// Full implementation requires HTTP + auth — stubbed with TODOs
// Vendor-neutral API client stub
// Generic client that can work with multiple providers
import "dart:io";
enum ApiProvider { anthropic, bedrock, vertex, foundry }
enum ApiProvider { generic, anthropic, openrouter, bedrock, vertex, foundry }
ApiProvider getApiProvider() {
final env = Platform.environment;
// Check for vendor-specific flags
if (_isTruthy(env["CLAUDE_CODE_USE_BEDROCK"])) return ApiProvider.bedrock;
if (_isTruthy(env["CLAUDE_CODE_USE_VERTEX"])) return ApiProvider.vertex;
if (_isTruthy(env["CLAUDE_CODE_USE_FOUNDRY"])) return ApiProvider.foundry;
if (_isTruthy(env["USE_OPENROUTER"])) return ApiProvider.openrouter;
if (_isTruthy(env["USE_ANTHROPIC"])) return ApiProvider.anthropic;
return ApiProvider.anthropic;
// Default to generic
return ApiProvider.generic;
}
bool _isTruthy(String? v) {
@@ -62,7 +65,19 @@ String? resolveApiKey() {
String resolveBaseUrl() {
final env = Platform.environment;
final override = env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
final override = env["ANTHROPIC_BASE_URL"] ??
env["CLAUDE_CODE_BASE_URL"] ??
env["OPENROUTER_BASE_URL"] ??
env["API_BASE_URL"];
if (override != null && override.isNotEmpty) return override;
return "https://api.anthropic.com";
// No vendor-specific defaults — require explicit configuration
throw StateError(
'Base URL not configured. Set one of:\n'
' ANTHROPIC_BASE_URL (for Anthropic)\n'
' CLAUDE_CODE_BASE_URL (for Claude Code backend)\n'
' OPENROUTER_BASE_URL (for OpenRouter)\n'
' API_BASE_URL (generic fallback)\n'
'Or use vendor-neutral kHostEndpoint from lib/src/constants.dart'
);
}
+11
View File
@@ -8,6 +8,7 @@ class ModelUsage {
int cacheReadInputTokens;
int cacheCreationInputTokens;
int webSearchRequests;
int webFetchRequests;
double costUsd;
int contextWindow;
int maxOutputTokens;
@@ -18,6 +19,7 @@ class ModelUsage {
this.cacheReadInputTokens = 0,
this.cacheCreationInputTokens = 0,
this.webSearchRequests = 0,
this.webFetchRequests = 0,
this.costUsd = 0.0,
this.contextWindow = 0,
this.maxOutputTokens = 0,
@@ -29,6 +31,7 @@ class ModelUsage {
"cacheReadInputTokens": cacheReadInputTokens,
"cacheCreationInputTokens": cacheCreationInputTokens,
"webSearchRequests": webSearchRequests,
"webFetchRequests": webFetchRequests,
"costUsd": costUsd,
};
@@ -38,6 +41,7 @@ class ModelUsage {
cacheReadInputTokens: (json["cacheReadInputTokens"] as num?)?.toInt() ?? 0,
cacheCreationInputTokens: (json["cacheCreationInputTokens"] as num?)?.toInt() ?? 0,
webSearchRequests: (json["webSearchRequests"] as num?)?.toInt() ?? 0,
webFetchRequests: (json["webFetchRequests"] as num?)?.toInt() ?? 0,
costUsd: (json["costUsd"] as num?)?.toDouble() ?? 0.0,
);
}
@@ -50,6 +54,7 @@ class _CostState {
int totalCacheReadInputTokens = 0;
int totalCacheCreationInputTokens = 0;
int totalWebSearchRequests = 0;
int totalWebFetchRequests = 0;
int totalApiDurationMs = 0;
int totalApiDurationWithoutRetriesMs = 0;
int totalToolDurationMs = 0;
@@ -69,6 +74,7 @@ int getTotalOutputTokens() => _state.totalOutputTokens;
int getTotalCacheReadInputTokens() => _state.totalCacheReadInputTokens;
int getTotalCacheCreationInputTokens() => _state.totalCacheCreationInputTokens;
int getTotalWebSearchRequests() => _state.totalWebSearchRequests;
int getTotalWebFetchRequests() => _state.totalWebFetchRequests;
int getTotalApiDurationMs() => _state.totalApiDurationMs;
int getTotalApiDurationWithoutRetriesMs() => _state.totalApiDurationWithoutRetriesMs;
int getTotalToolDurationMs() => _state.totalToolDurationMs;
@@ -98,6 +104,7 @@ void resetCostState() {
_state.totalCacheReadInputTokens = 0;
_state.totalCacheCreationInputTokens = 0;
_state.totalWebSearchRequests = 0;
_state.totalWebFetchRequests = 0;
_state.totalApiDurationMs = 0;
_state.totalApiDurationWithoutRetriesMs = 0;
_state.totalToolDurationMs = 0;
@@ -142,6 +149,7 @@ double addToTotalSessionCost({
required int cacheReadTokens,
required int cacheCreationTokens,
int webSearchRequests = 0,
int webFetchRequests = 0,
required String model,
}) {
_state.totalCostUsd += cost;
@@ -150,6 +158,7 @@ double addToTotalSessionCost({
_state.totalCacheReadInputTokens += cacheReadTokens;
_state.totalCacheCreationInputTokens += cacheCreationTokens;
_state.totalWebSearchRequests += webSearchRequests;
_state.totalWebFetchRequests += webFetchRequests;
final existing = _state.modelUsage.putIfAbsent(model, ModelUsage.new);
existing.inputTokens += inputTokens;
@@ -157,6 +166,7 @@ double addToTotalSessionCost({
existing.cacheReadInputTokens += cacheReadTokens;
existing.cacheCreationInputTokens += cacheCreationTokens;
existing.webSearchRequests += webSearchRequests;
existing.webFetchRequests += webFetchRequests;
existing.costUsd += cost;
return _state.totalCostUsd;
@@ -209,6 +219,7 @@ String formatTotalCost() {
"${_fmt(u.cacheReadInputTokens)} cache read, "
"${_fmt(u.cacheCreationInputTokens)} cache write"
"${u.webSearchRequests > 0 ? ", ${_fmt(u.webSearchRequests)} web search" : ""}"
"${u.webFetchRequests > 0 ? ", ${_fmt(u.webFetchRequests)} web fetch" : ""}"
" (${formatCost(u.costUsd)})";
final label = "${entry.key}:".padLeft(21);
buf.write("\n$label$line");
+236
View File
@@ -0,0 +1,236 @@
// Process management service for task execution
// Handles spawning, monitoring, and terminating sub-processes
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../tools/task_tool.dart';
typedef ProcessCallback = void Function(String output);
/// Represents a running process
class ManagedProcess {
final String id;
final String taskId;
final String command;
final List<String> arguments;
final String workingDirectory;
late Process _process;
late StreamSubscription<String> _stdoutSub;
late StreamSubscription<String> _stderrSub;
final List<String> _output = [];
final List<String> _errors = [];
DateTime _startTime = DateTime.now();
DateTime? _endTime;
int? _exitCode;
bool _isRunning = true;
ManagedProcess({
required this.id,
required this.taskId,
required this.command,
required this.arguments,
required this.workingDirectory,
});
/// Start the process
Future<void> start() async {
try {
_process = await Process.start(
command,
arguments,
workingDirectory: workingDirectory,
includeParentEnvironment: true,
);
// Listen to stdout
_stdoutSub = _process.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
_output.add(line);
});
// Listen to stderr
_stderrSub = _process.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
_errors.add(line);
});
// Wait for process to exit
_exitCode = await _process.exitCode;
_isRunning = false;
_endTime = DateTime.now();
// Cancel subscriptions
await _stdoutSub.cancel();
await _stderrSub.cancel();
} catch (e) {
_isRunning = false;
_endTime = DateTime.now();
_errors.add('Failed to start process: $e');
rethrow;
}
}
/// Terminate the process
Future<bool> terminate({bool force = false}) async {
if (!_isRunning) {
return true;
}
try {
if (force) {
// Force kill
return _process.kill(ProcessSignal.sigkill);
} else {
// Graceful shutdown
_process.kill(ProcessSignal.sigterm);
// Wait up to 5 seconds for graceful shutdown
for (int i = 0; i < 50; i++) {
await Future.delayed(const Duration(milliseconds: 100));
if (!_isRunning) {
return true;
}
}
// If still running, force kill
return _process.kill(ProcessSignal.sigkill);
}
} catch (e) {
return false;
}
}
/// Get current output
String getOutput() => _output.join('\n');
/// Get current errors
String getErrors() => _errors.join('\n');
/// Get full output (both stdout and stderr)
String getFullOutput() {
final combined = <String>[];
combined.addAll(_output);
if (_errors.isNotEmpty) {
combined.add('\n--- STDERR ---');
combined.addAll(_errors);
}
return combined.join('\n');
}
/// Public getters for process state
bool get isRunning => _isRunning;
int? get exitCode => _exitCode;
DateTime get startTime => _startTime;
DateTime? get endTime => _endTime;
/// Get process info
Map<String, dynamic> toJson() {
final duration = (_endTime ?? DateTime.now()).difference(_startTime);
return {
'id': id,
'task_id': taskId,
'command': command,
'arguments': arguments,
'working_directory': workingDirectory,
'is_running': _isRunning,
'exit_code': _exitCode,
'duration_ms': duration.inMilliseconds,
'output_lines': _output.length,
'error_lines': _errors.length,
'started_at': _startTime.toIso8601String(),
'ended_at': _endTime?.toIso8601String(),
};
}
}
/// Manages multiple processes
class ProcessManager {
static final ProcessManager _instance = ProcessManager._internal();
factory ProcessManager() => _instance;
ProcessManager._internal();
final Map<String, ManagedProcess> _processes = {};
int _idCounter = 1;
/// Spawn a new process
Future<ManagedProcess> spawn({
required String taskId,
required String command,
required List<String> arguments,
required String workingDirectory,
}) async {
final id = 'proc_${_idCounter++}';
final process = ManagedProcess(
id: id,
taskId: taskId,
command: command,
arguments: arguments,
workingDirectory: workingDirectory,
);
_processes[id] = process;
// Start in background
process.start().then((_) {
// Process completed
}).catchError((e) {
print('Process $id failed: $e');
});
return process;
}
/// Get a process by ID
ManagedProcess? getProcess(String id) => _processes[id];
/// Get all processes for a task
List<ManagedProcess> getTaskProcesses(String taskId) {
return _processes.values
.where((p) => p.taskId == taskId)
.toList();
}
/// Terminate a process
Future<bool> terminateProcess(String id, {bool force = false}) async {
final process = _processes[id];
if (process == null) {
return false;
}
final result = await process.terminate(force: force);
if (result) {
_processes.remove(id);
}
return result;
}
/// Terminate all processes for a task
Future<void> terminateTaskProcesses(String taskId, {bool force = false}) async {
final processes = getTaskProcesses(taskId);
for (final process in processes) {
await process.terminate(force: force);
_processes.remove(process.id);
}
}
/// Get all processes
List<ManagedProcess> getAllProcesses() => _processes.values.toList();
/// Get process count
int getProcessCount() => _processes.length;
/// Get running process count
int getRunningProcessCount() =>
_processes.values.where((p) => p.isRunning).length;
}
+206
View File
@@ -0,0 +1,206 @@
// Task execution service
// Bridges task definitions to actual process execution
import 'dart:async';
import 'process_manager.dart';
/// Status of a task execution
enum TaskExecutionStatus {
created,
queued,
running,
completed,
failed,
cancelled,
}
/// Result of a task execution
class TaskExecutionResult {
final String taskId;
final String processId;
final TaskExecutionStatus status;
final int? exitCode;
final String output;
final String errors;
final Duration? duration;
const TaskExecutionResult({
required this.taskId,
required this.processId,
required this.status,
this.exitCode,
required this.output,
required this.errors,
this.duration,
});
bool get isSuccess => exitCode == 0;
bool get isRunning => status == TaskExecutionStatus.running;
Map<String, dynamic> toJson() => {
'task_id': taskId,
'process_id': processId,
'status': status.name,
'exit_code': exitCode,
'output': output,
'errors': errors,
'duration_ms': duration?.inMilliseconds,
'is_success': isSuccess,
};
}
/// Executes tasks as background processes
class TaskExecutor {
static final TaskExecutor _instance = TaskExecutor._internal();
factory TaskExecutor() => _instance;
TaskExecutor._internal() : _processManager = ProcessManager();
final ProcessManager _processManager;
final Map<String, TaskExecutionResult> _results = {};
/// Execute a task command
/// Returns immediately with process ID
/// Call getResult() to check completion
Future<String> executeTask({
required String taskId,
required String command,
required List<String> arguments,
required String workingDirectory,
}) async {
try {
final process = await _processManager.spawn(
taskId: taskId,
command: command,
arguments: arguments,
workingDirectory: workingDirectory,
);
return process.id;
} catch (e) {
throw TaskExecutionException(
'Failed to execute task: $e',
taskId: taskId,
);
}
}
/// Get execution result (non-blocking)
/// Returns null if process still running
/// Returns result when complete
TaskExecutionResult? getResult(String processId) {
final process = _processManager.getProcess(processId);
if (process == null) {
return _results[processId];
}
// Process still exists, check if running
if (process.isRunning) {
return null; // Still running
}
// Process completed, return result
final result = TaskExecutionResult(
taskId: process.taskId,
processId: processId,
status: process.exitCode == 0
? TaskExecutionStatus.completed
: TaskExecutionStatus.failed,
exitCode: process.exitCode,
output: process.getOutput(),
errors: process.getErrors(),
duration: process.endTime?.difference(process.startTime),
);
_results[processId] = result;
return result;
}
/// Wait for process completion
/// Polls for result (blocking)
Future<TaskExecutionResult> waitForResult(
String processId, {
Duration timeout = const Duration(hours: 24),
}) async {
final endTime = DateTime.now().add(timeout);
while (DateTime.now().isBefore(endTime)) {
final result = getResult(processId);
if (result != null) {
return result;
}
// Wait 100ms before checking again
await Future.delayed(const Duration(milliseconds: 100));
}
throw TaskExecutionException(
'Task execution timeout after ${timeout.inSeconds} seconds',
taskId: '',
);
}
/// Watch process output in real-time
/// Returns stream of output lines
Stream<String> watchOutput(String processId) async* {
final process = _processManager.getProcess(processId);
if (process == null) {
return;
}
// Initial output
final output = process.getOutput();
if (output.isNotEmpty) {
for (final line in output.split('\n')) {
yield line;
}
}
// Wait for more output (simplified - real version would stream)
while (process.isRunning) {
await Future.delayed(const Duration(milliseconds: 500));
final newOutput = process.getOutput();
if (newOutput.isNotEmpty) {
yield newOutput;
}
}
}
/// Terminate a task
Future<bool> cancelTask(String processId, {bool force = false}) async {
return _processManager.terminateProcess(processId, force: force);
}
/// Get all active tasks
List<Map<String, dynamic>> getActiveTasks() {
return _processManager.getAllProcesses().map((p) => p.toJson()).toList();
}
/// Get task status
TaskExecutionStatus getTaskStatus(String processId) {
final process = _processManager.getProcess(processId);
if (process == null) {
final result = _results[processId];
return result?.status ?? TaskExecutionStatus.cancelled;
}
if (process.isRunning) {
return TaskExecutionStatus.running;
}
return process.exitCode == 0
? TaskExecutionStatus.completed
: TaskExecutionStatus.failed;
}
}
/// Exception thrown during task execution
class TaskExecutionException implements Exception {
final String message;
final String taskId;
TaskExecutionException(this.message, {required this.taskId});
@override
String toString() => message;
}
@@ -0,0 +1,50 @@
import "../analytics/analytics_service.dart";
import "analytics_config.dart";
abstract class ToolTelemetryClient {
Future<void> recordToolCall({
required String toolName,
required bool success,
int? durationMs,
Map<String, Object?> metadata = const <String, Object?>{},
});
}
class NullToolTelemetryClient implements ToolTelemetryClient {
const NullToolTelemetryClient();
@override
Future<void> recordToolCall({
required String toolName,
required bool success,
int? durationMs,
Map<String, Object?> metadata = const <String, Object?>{},
}) async {}
}
class LocalToolTelemetryClient implements ToolTelemetryClient {
const LocalToolTelemetryClient();
@override
Future<void> recordToolCall({
required String toolName,
required bool success,
int? durationMs,
Map<String, Object?> metadata = const <String, Object?>{},
}) async {
final eventMetadata = <String, Object?>{
"tool_name": toolName,
"success": success,
if (durationMs != null) "duration_ms": durationMs,
...metadata,
};
logAnalyticsEvent("tool_call", eventMetadata);
}
}
ToolTelemetryClient createToolTelemetryClient() {
if (isTelemetryDisabled()) {
return const NullToolTelemetryClient();
}
return const LocalToolTelemetryClient();
}
+396
View File
@@ -0,0 +1,396 @@
import 'dart:convert';
import 'dart:io';
import '../constants.dart';
/// Usage tracking and quota management
/// Tracks API usage, tool usage, and enforces limits
class UsageTracker {
static final UsageTracker _instance = UsageTracker._internal();
factory UsageTracker() => _instance;
UsageTracker._internal();
bool _enabled = true;
bool _initialized = false;
// Usage counters
final Map<String, dynamic> _usage = {
'daily': _resetDailyCounters(),
'monthly': _resetMonthlyCounters(),
'total': _resetTotalCounters(),
};
// Quota limits (would be loaded from config/remote)
final Map<String, dynamic> _limits = {
'daily': {
'api_calls': 1000,
'tokens': 1000000,
'tool_executions': 1000,
'cost_usd': 10.0,
},
'monthly': {
'api_calls': 30000,
'tokens': 30000000,
'tool_executions': 30000,
'cost_usd': 300.0,
},
};
// Last reset times
DateTime _lastDailyReset = DateTime.now();
DateTime _lastMonthlyReset = DateTime.now();
/// Initialize usage tracker
Future<void> initialize({bool enabled = true}) async {
if (_initialized) return;
_enabled = enabled && areRemoteServicesAvailable();
_initialized = true;
// Load saved usage data
await _loadUsageData();
// Check if we need to reset counters
await _checkAndResetCounters();
// Sync with remote service if available
if (shouldUseRemoteService('usage')) {
await _syncWithRemote();
}
}
/// Track API usage
Future<void> trackApiCall(String model,
{int? inputTokens,
int? outputTokens,
double? costUsd,
int? durationMs}) async {
if (!_enabled) return;
await _checkAndResetCounters();
final counters = _usage['daily'] as Map<String, dynamic>;
final monthly = _usage['monthly'] as Map<String, dynamic>;
final total = _usage['total'] as Map<String, dynamic>;
// Update counters
counters['api_calls'] = (counters['api_calls'] as int) + 1;
monthly['api_calls'] = (monthly['api_calls'] as int) + 1;
total['api_calls'] = (total['api_calls'] as int) + 1;
if (inputTokens != null) {
counters['tokens'] = (counters['tokens'] as int) + inputTokens;
monthly['tokens'] = (monthly['tokens'] as int) + inputTokens;
total['tokens'] = (total['tokens'] as int) + inputTokens;
}
if (outputTokens != null) {
counters['tokens'] = (counters['tokens'] as int) + outputTokens;
monthly['tokens'] = (monthly['tokens'] as int) + outputTokens;
total['tokens'] = (total['tokens'] as int) + outputTokens;
}
if (costUsd != null) {
counters['cost_usd'] = (counters['cost_usd'] as double) + costUsd;
monthly['cost_usd'] = (monthly['cost_usd'] as double) + costUsd;
total['cost_usd'] = (total['cost_usd'] as double) + costUsd;
}
// Track per-model usage
if (!counters.containsKey('models')) {
counters['models'] = {};
monthly['models'] = {};
total['models'] = {};
}
final modelCounters = counters['models'] as Map<String, dynamic>;
final monthlyModels = monthly['models'] as Map<String, dynamic>;
final totalModels = total['models'] as Map<String, dynamic>;
modelCounters[model] = (modelCounters[model] as int? ?? 0) + 1;
monthlyModels[model] = (monthlyModels[model] as int? ?? 0) + 1;
totalModels[model] = (totalModels[model] as int? ?? 0) + 1;
await _saveUsageData();
await _checkLimits();
}
/// Track tool execution
Future<void> trackToolExecution(String toolName,
{int? durationMs, Map<String, dynamic>? input}) async {
if (!_enabled) return;
await _checkAndResetCounters();
final counters = _usage['daily'] as Map<String, dynamic>;
final monthly = _usage['monthly'] as Map<String, dynamic>;
final total = _usage['total'] as Map<String, dynamic>;
counters['tool_executions'] = (counters['tool_executions'] as int) + 1;
monthly['tool_executions'] = (monthly['tool_executions'] as int) + 1;
total['tool_executions'] = (total['tool_executions'] as int) + 1;
// Track per-tool usage
if (!counters.containsKey('tools')) {
counters['tools'] = {};
monthly['tools'] = {};
total['tools'] = {};
}
final toolCounters = counters['tools'] as Map<String, dynamic>;
final monthlyTools = monthly['tools'] as Map<String, dynamic>;
final totalTools = total['tools'] as Map<String, dynamic>;
toolCounters[toolName] = (toolCounters[toolName] as int? ?? 0) + 1;
monthlyTools[toolName] = (monthlyTools[toolName] as int? ?? 0) + 1;
totalTools[toolName] = (totalTools[toolName] as int? ?? 0) + 1;
await _saveUsageData();
}
/// Check if usage is within limits
Future<Map<String, dynamic>> checkLimits() async {
await _checkAndResetCounters();
final daily = _usage['daily'] as Map<String, dynamic>;
final monthly = _usage['monthly'] as Map<String, dynamic>;
final dailyLimits = _limits['daily'] as Map<String, dynamic>;
final monthlyLimits = _limits['monthly'] as Map<String, dynamic>;
final violations = <String, List<String>>{
'daily': [],
'monthly': [],
};
// Check daily limits
for (final key in dailyLimits.keys) {
if (key == 'cost_usd') {
final used = daily[key] as double;
final limit = dailyLimits[key] as double;
if (used > limit) {
violations['daily']!.add('$key: $used/$limit');
}
} else {
final used = daily[key] as int;
final limit = dailyLimits[key] as int;
if (used > limit) {
violations['daily']!.add('$key: $used/$limit');
}
}
}
// Check monthly limits
for (final key in monthlyLimits.keys) {
if (key == 'cost_usd') {
final used = monthly[key] as double;
final limit = monthlyLimits[key] as double;
if (used > limit) {
violations['monthly']!.add('$key: $used/$limit');
}
} else {
final used = monthly[key] as int;
final limit = monthlyLimits[key] as int;
if (used > limit) {
violations['monthly']!.add('$key: $used/$limit');
}
}
}
return {
'within_limits': violations['daily']!.isEmpty && violations['monthly']!.isEmpty,
'violations': violations,
'usage': {
'daily': daily,
'monthly': monthly,
'total': _usage['total'],
},
'limits': _limits,
};
}
/// Get usage summary
Map<String, dynamic> getUsageSummary() {
return {
'enabled': _enabled,
'daily': Map<String, dynamic>.from(_usage['daily'] as Map),
'monthly': Map<String, dynamic>.from(_usage['monthly'] as Map),
'total': Map<String, dynamic>.from(_usage['total'] as Map),
'limits': Map<String, dynamic>.from(_limits),
'last_daily_reset': _lastDailyReset.toIso8601String(),
'last_monthly_reset': _lastMonthlyReset.toIso8601String(),
};
}
/// Reset usage counters
Future<void> resetCounters({bool daily = false, bool monthly = false}) async {
if (daily) {
_usage['daily'] = _resetDailyCounters();
_lastDailyReset = DateTime.now();
}
if (monthly) {
_usage['monthly'] = _resetMonthlyCounters();
_lastMonthlyReset = DateTime.now();
}
await _saveUsageData();
}
// Private methods
Future<void> _checkAndResetCounters() async {
final now = DateTime.now();
// Check daily reset (reset at midnight)
if (now.day != _lastDailyReset.day ||
now.month != _lastDailyReset.month ||
now.year != _lastDailyReset.year) {
_usage['daily'] = _resetDailyCounters();
_lastDailyReset = now;
}
// Check monthly reset (reset on 1st of month)
if (now.month != _lastMonthlyReset.month ||
now.year != _lastMonthlyReset.year) {
_usage['monthly'] = _resetMonthlyCounters();
_lastMonthlyReset = now;
}
}
Future<void> _checkLimits() async {
final limits = await checkLimits();
if (!limits['within_limits'] as bool) {
// Log limit violations
final violations = limits['violations'] as Map<String, List<String>>;
for (final period in violations.keys) {
if (violations[period]!.isNotEmpty) {
print('Warning: $period usage limits exceeded: ${violations[period]!.join(', ')}');
}
}
}
}
Future<void> _syncWithRemote() async {
if (!shouldUseRemoteService('usage')) {
return;
}
try {
final url = getRemoteServiceUrl('${ApiPaths.usage}/sync');
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({
'usage': _usage,
'timestamp': DateTime.now().toUtc().toIso8601String(),
}));
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>;
// Update limits from remote
if (data.containsKey('limits')) {
_limits.clear();
_limits.addAll(Map<String, dynamic>.from(data['limits'] as Map));
}
}
await response.drain();
} catch (e) {
// Silently fail - we'll try again later
}
}
Future<void> _loadUsageData() async {
final usageFile = File(_getUsageFilePath());
if (!await usageFile.exists()) return;
try {
final data = jsonDecode(await usageFile.readAsString()) as Map<String, dynamic>;
if (data.containsKey('usage')) {
_usage.clear();
_usage.addAll(Map<String, dynamic>.from(data['usage'] as Map));
}
if (data.containsKey('last_daily_reset')) {
_lastDailyReset = DateTime.parse(data['last_daily_reset'] as String);
}
if (data.containsKey('last_monthly_reset')) {
_lastMonthlyReset = DateTime.parse(data['last_monthly_reset'] as String);
}
if (data.containsKey('limits')) {
_limits.clear();
_limits.addAll(Map<String, dynamic>.from(data['limits'] as Map));
}
} catch (e) {
// Reset on error
_usage['daily'] = _resetDailyCounters();
_usage['monthly'] = _resetMonthlyCounters();
_usage['total'] = _resetTotalCounters();
}
}
Future<void> _saveUsageData() async {
final usageFile = File(_getUsageFilePath());
final data = {
'usage': _usage,
'last_daily_reset': _lastDailyReset.toIso8601String(),
'last_monthly_reset': _lastMonthlyReset.toIso8601String(),
'limits': _limits,
'saved_at': DateTime.now().toUtc().toIso8601String(),
};
try {
await usageFile.parent.create(recursive: true);
await usageFile.writeAsString(jsonEncode(data));
} catch (e) {
// Silently fail if we can't write to file
}
}
static Map<String, dynamic> _resetDailyCounters() {
return {
'api_calls': 0,
'tokens': 0,
'tool_executions': 0,
'cost_usd': 0.0,
'models': {},
'tools': {},
};
}
static Map<String, dynamic> _resetMonthlyCounters() {
return {
'api_calls': 0,
'tokens': 0,
'tool_executions': 0,
'cost_usd': 0.0,
'models': {},
'tools': {},
};
}
static Map<String, dynamic> _resetTotalCounters() {
return {
'api_calls': 0,
'tokens': 0,
'tool_executions': 0,
'cost_usd': 0.0,
'models': {},
'tools': {},
};
}
static String _getUsageFilePath() {
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
Directory.current.path;
return Platform.isWindows
? '$home\\.clawd_code\\usage.json'
: '$home/.clawd_code/usage.json';
}
}
+15 -2
View File
@@ -21,10 +21,10 @@ class ConversationHistory {
_session = s;
}
void addMessage(String role, String content, {int? tokens}) {
void addMessage(String role, String content, {int? tokens, int? contextTokens}) {
if (_session == null) return;
final msg = Message(role: role, content: content, tokens: tokens);
final msg = Message(role: role, content: content, tokens: tokens, contextTokens: contextTokens);
_session!.messages.add(msg);
_session!.updated = DateTime.now().toUtc();
@@ -41,10 +41,23 @@ class ConversationHistory {
content: "${lastMessage.content}$text",
timestamp: lastMessage.timestamp,
tokens: lastMessage.tokens,
contextTokens: lastMessage.contextTokens,
);
_session!.updated = DateTime.now().toUtc();
}
void setLastMessageContextTokens(int contextTokens) {
if (_session == null || _session!.messages.isEmpty) return;
final last = _session!.messages.last;
_session!.messages[_session!.messages.length - 1] = Message(
role: last.role,
content: last.content,
timestamp: last.timestamp,
tokens: last.tokens,
contextTokens: contextTokens,
);
}
void removeLastMessage() {
if (_session == null || _session!.messages.isEmpty) {
return;
+40 -45
View File
@@ -1,18 +1,27 @@
// Parity exception: Claude Code stores sessions in ~/.claude/projects/<sanitized-cwd>/<id>.jsonl
// The Agency uses <workingDirectory>/.the_agency/sessions/<id>.json instead, because the UI
// is multi-project and it makes more sense for session data to live alongside the project.
import "dart:convert";
import "dart:io";
import "../local_state.dart";
import "package:path/path.dart" as p;
import "session_types.dart";
const _encoder = JsonEncoder.withIndent(" ");
// sessions live in ~/.clawd_code/sessions/{id}.json
String getSessionsDir() {
return joinPath(getConfigHomeDir(), "sessions");
// sessions live in <workingDirectory>/.the_agency/sessions/{id}.json
String getProjectAgencyDir(String workingDirectory) {
return p.join(workingDirectory, ".the_agency");
}
String _sessionPath(String id) {
return joinPath(getSessionsDir(), "$id.json");
String getProjectSessionsDir(String workingDirectory) {
return p.join(getProjectAgencyDir(workingDirectory), "sessions");
}
String _sessionPath(String workingDirectory, String id) {
return p.join(getProjectSessionsDir(workingDirectory), "$id.json");
}
class SessionStore {
@@ -20,20 +29,22 @@ class SessionStore {
static final SessionStore instance = SessionStore._();
Future<void> saveSession(ConversationSession session) async {
final dir = Directory(getSessionsDir());
final workingDir = session.workingDirectory;
if (workingDir == null || workingDir.isEmpty) return;
final dir = Directory(getProjectSessionsDir(workingDir));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final file = File(_sessionPath(session.id));
final file = File(_sessionPath(workingDir, session.id));
final json = _encoder.convert(session.toJson());
await file.writeAsString("$json\n");
}
Future<ConversationSession?> loadSession(String id) async {
final file = File(_sessionPath(id));
Future<ConversationSession?> loadSession(String id, {required String workingDirectory}) async {
final file = File(_sessionPath(workingDirectory, id));
if (!await file.exists()) return null;
try {
@@ -43,15 +54,15 @@ class SessionStore {
return ConversationSession.fromJson(decoded);
}
} catch (_) {
// corrupt file - just return null
// corrupt file return null
}
return null;
}
// returns summaries sorted newest first
Future<List<SessionSummary>> listSessions() async {
final dir = Directory(getSessionsDir());
// lists sessions for a single project, sorted newest first
Future<List<SessionSummary>> listSessionsForProject(String workingDirectory) async {
final dir = Directory(getProjectSessionsDir(workingDirectory));
if (!await dir.exists()) return <SessionSummary>[];
final summaries = <SessionSummary>[];
@@ -76,39 +87,23 @@ class SessionStore {
return summaries;
}
Future<bool> deleteSession(String id) async {
final file = File(_sessionPath(id));
// lists all sessions across multiple projects, sorted newest first
Future<List<SessionSummary>> listAllSessions(List<String> workingDirectories) async {
final all = <SessionSummary>[];
for (final dir in workingDirectories) {
all.addAll(await listSessionsForProject(dir));
}
all.sort((a, b) => b.updated.compareTo(a.updated));
return all;
}
Future<bool> deleteSession(String id, {required String workingDirectory}) async {
final file = File(_sessionPath(workingDirectory, id));
if (!await file.exists()) return false;
await file.delete();
return true;
}
// case insensitive search by name
Future<ConversationSession?> findSessionByName(String name) async {
final dir = Directory(getSessionsDir());
if (!await dir.exists()) return null;
final lowerName = name.toLowerCase();
await for (final entity in dir.list()) {
if (entity is! File) continue;
if (!entity.path.endsWith(".json")) continue;
try {
final raw = await entity.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
final sess = ConversationSession.fromJson(decoded);
if (sess.name.toLowerCase() == lowerName) {
return sess;
}
}
} catch (_) {
continue;
}
}
return null;
}
}
+7
View File
@@ -7,6 +7,7 @@ class Message {
required this.content,
DateTime? timestamp,
this.tokens,
this.contextTokens,
}) : timestamp = timestamp ?? DateTime.now().toUtc();
factory Message.fromJson(Map<String, dynamic> json) {
@@ -17,6 +18,7 @@ class Message {
? DateTime.tryParse(json["timestamp"] as String)
: null,
tokens: json["tokens"] as int?,
contextTokens: json["contextTokens"] as int?,
);
}
@@ -27,12 +29,17 @@ class Message {
// approx token count - may be null if not tracked
final int? tokens;
// full context window size from the last API response usage field
// (input + cache_creation + cache_read + output), same as Claude Code
final int? contextTokens;
Map<String, dynamic> toJson() {
return <String, dynamic>{
"role": role,
"content": content,
"timestamp": timestamp.toIso8601String(),
if (tokens != null) "tokens": tokens,
if (contextTokens != null) "contextTokens": contextTokens,
};
}
+696
View File
@@ -0,0 +1,696 @@
// CLAUDE.md loader — mirrors claude code's claudemd.ts behaviour.
//
// Loading order (later = higher priority, model pays more attention):
// 1. Managed (/Library/Application Support/ClaudeCode/CLAUDE.md on mac,
// /etc/claude-code/CLAUDE.md on linux,
// C:\Program Files\ClaudeCode\CLAUDE.md on windows)
// 2. User (~/.claude/CLAUDE.md, ~/.claude/rules/*.md)
// 3. Project (CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md)
// walking up from cwd to root, root first (cwd wins)
// 4. Local (CLAUDE.local.md, same traversal)
//
// @include directive:
// @path, @./relative, @~/home, @/absolute — only in text nodes,
// not inside fenced code blocks. Max depth 5. Circular refs skipped.
//
// HTML comment stripping:
// Block-level HTML comments (lines starting with <!--) are stripped.
// Comments inside fenced code blocks are preserved.
import "dart:io";
import "package:path/path.dart" as p;
import "frontmatter_parser.dart";
import "memory_file_info.dart";
import "memory_types.dart";
const int _maxIncludeDepth = 5;
// Only text-like extensions are allowed in @include directives
// (prevents loading binary files like images, PDFs, etc.)
const _textFileExtensions = {
".md", ".txt", ".text",
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
".html", ".htm", ".css", ".scss", ".sass", ".less",
".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
".py", ".pyi", ".pyw",
".rb", ".erb", ".rake",
".go",
".rs",
".java", ".kt", ".kts", ".scala",
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx",
".cs",
".swift",
".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd",
".env", ".ini", ".cfg", ".conf", ".config", ".properties",
".sql", ".graphql", ".gql",
".proto",
".vue", ".svelte", ".astro",
".ejs", ".hbs", ".pug", ".jade",
".php", ".pl", ".pm", ".lua", ".r", ".R", ".dart",
".ex", ".exs", ".erl", ".hrl",
".clj", ".cljs", ".cljc", ".edn",
".hs", ".lhs", ".elm",
".ml", ".mli",
".f", ".f90", ".f95", ".for",
".cmake", ".make", ".makefile", ".gradle", ".sbt",
".rst", ".adoc", ".asciidoc", ".org", ".tex", ".latex",
".lock", ".log", ".diff", ".patch",
};
const _memoryInstructionPrompt =
"Codebase and user instructions are shown below. Be sure to adhere to these"
" instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you"
" MUST follow them exactly as written.";
// ──────────────────────────────────────────────────────────────────────────────
// Platform paths
// ──────────────────────────────────────────────────────────────────────────────
String _getManagedFilePath() {
if (Platform.isMacOS) return "/Library/Application Support/ClaudeCode";
if (Platform.isWindows) return r"C:\Program Files\ClaudeCode";
return "/etc/claude-code";
}
String _getClaudeConfigHomeDir() {
final override = Platform.environment["CLAUDE_CONFIG_DIR"];
if (override != null && override.isNotEmpty) return override;
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
return p.join(home, ".claude");
}
// ──────────────────────────────────────────────────────────────────────────────
// HTML comment stripping
// ──────────────────────────────────────────────────────────────────────────────
// Strips block-level HTML comments from markdown content.
// Comments inside fenced code blocks are preserved.
// Unclosed comments (<!-- with no -->) are left in place.
String stripHtmlComments(String content) {
if (!content.contains("<!--")) return content;
final lines = content.split("\n");
final result = StringBuffer();
var inFence = false;
String? fenceChar;
var inComment = false;
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
final raw = line.trimLeft();
// Track fenced code blocks (``` or ~~~)
if (!inComment) {
if (!inFence) {
if (raw.startsWith("```") || raw.startsWith("~~~")) {
inFence = true;
fenceChar = raw.startsWith("```") ? "```" : "~~~";
result.write(line);
if (i < lines.length - 1) result.write("\n");
continue;
}
} else {
if (raw.startsWith(fenceChar!)) {
inFence = false;
fenceChar = null;
}
result.write(line);
if (i < lines.length - 1) result.write("\n");
continue;
}
}
if (inFence) {
result.write(line);
if (i < lines.length - 1) result.write("\n");
continue;
}
// block-level HTML comment starts with <!-- (up to 3 leading spaces allowed)
if (!inComment && raw.startsWith("<!--")) {
// check if it closes on the same line/block
final commentSpan = RegExp(r"<!--[\s\S]*?-->");
final residue = line.replaceAll(commentSpan, "");
if (line.contains("-->")) {
// comment closed — keep any residue
if (residue.trim().isNotEmpty) {
result.write(residue);
if (i < lines.length - 1) result.write("\n");
}
continue;
} else {
// multi-line comment — enter comment mode
inComment = true;
continue;
}
}
if (inComment) {
if (line.contains("-->")) {
inComment = false;
// strip up to and including -->
final after = line.substring(line.indexOf("-->") + 3);
if (after.trim().isNotEmpty) {
result.write(after);
if (i < lines.length - 1) result.write("\n");
}
}
// skip lines inside comment block
continue;
}
result.write(line);
if (i < lines.length - 1) result.write("\n");
}
return result.toString();
}
// ──────────────────────────────────────────────────────────────────────────────
// @include path extraction
// ──────────────────────────────────────────────────────────────────────────────
// Mirrors extractIncludePathsFromTokens from Claude Code.
// Finds @path patterns in text nodes (skips code blocks and HTML comments).
List<String> _extractIncludePaths(String content, String fileDir) {
final absolutePaths = <String>{};
final includeRegex = RegExp(r"(?:^|\s)@((?:[^\s\\]|\\ )+)");
final lines = content.split("\n");
var inFence = false;
String? fenceChar;
var inComment = false;
for (final line in lines) {
final raw = line.trimLeft();
// Track fenced code blocks
if (!inFence && !inComment) {
if (raw.startsWith("```") || raw.startsWith("~~~")) {
inFence = true;
fenceChar = raw.startsWith("```") ? "```" : "~~~";
continue;
}
} else if (inFence) {
if (raw.startsWith(fenceChar!)) {
inFence = false;
fenceChar = null;
}
continue;
}
// Track HTML comment blocks (skip @includes inside them)
if (!inComment && raw.startsWith("<!--")) {
if (line.contains("-->")) {
// single-line comment — strip and check residue
final residue = line.replaceAll(RegExp(r"<!--[\s\S]*?-->"), "");
_extractPathsFromText(residue, fileDir, includeRegex, absolutePaths);
} else {
inComment = true;
}
continue;
}
if (inComment) {
if (line.contains("-->")) inComment = false;
continue;
}
_extractPathsFromText(line, fileDir, includeRegex, absolutePaths);
}
return absolutePaths.toList();
}
void _extractPathsFromText(
String text,
String fileDir,
RegExp includeRegex,
Set<String> absolutePaths,
) {
for (final match in includeRegex.allMatches(text)) {
var path = match.group(1);
if (path == null || path.isEmpty) continue;
// strip fragment identifiers
final hashIdx = path.indexOf("#");
if (hashIdx != -1) path = path.substring(0, hashIdx);
if (path.isEmpty) continue;
// unescape spaces
path = path.replaceAll(r"\ ", " ");
final isValid = path.startsWith("./") ||
path.startsWith("~/") ||
(path.startsWith("/") && path != "/") ||
(!path.startsWith("@") &&
!RegExp(r"^[#%^&*()]+").hasMatch(path) &&
RegExp(r"^[a-zA-Z0-9._-]").hasMatch(path));
if (!isValid) continue;
final resolved = _expandPath(path, fileDir);
absolutePaths.add(resolved);
}
}
String _expandPath(String path, String baseDir) {
if (path.startsWith("~/")) {
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
return p.join(home, path.substring(2));
}
if (p.isAbsolute(path)) return path;
return p.normalize(p.join(baseDir, path));
}
// ──────────────────────────────────────────────────────────────────────────────
// Frontmatter paths extraction
// ──────────────────────────────────────────────────────────────────────────────
// Returns null if no paths restriction, empty/non-empty list if restricted.
// Strips /** suffix like Claude Code does.
List<String>? _parseFrontmatterPaths(Map<String, dynamic> frontmatter) {
final raw = frontmatter["paths"];
if (raw == null) return null;
final patterns = splitPathInFrontmatter(raw)
.map((p) => p.endsWith("/**") ? p.substring(0, p.length - 3) : p)
.where((p) => p.isNotEmpty)
.toList();
// if all patterns are ** (match-all), treat as no restriction
if (patterns.isEmpty || patterns.every((p) => p == "**")) return null;
return patterns;
}
// ──────────────────────────────────────────────────────────────────────────────
// Core file processor
// ──────────────────────────────────────────────────────────────────────────────
// Recursively processes a memory file and all its @include references.
// Returns includes-first list (same order as Claude Code).
Future<List<MemoryFileInfo>> processMemoryFile(
String filePath,
MemoryType type,
Set<String> processedPaths, {
bool includeExternal = false,
int depth = 0,
String? parent,
String? originalCwd,
}) async {
final normalizedPath = p.normalize(filePath);
if (processedPaths.contains(normalizedPath) || depth >= _maxIncludeDepth) {
return [];
}
// Extension check — skip non-text files (like Claude Code does for @include)
final ext = p.extension(filePath).toLowerCase();
if (ext.isNotEmpty && !_textFileExtensions.contains(ext)) return [];
processedPaths.add(normalizedPath);
String rawContent;
try {
final file = File(filePath);
// resolve symlinks
String resolvedPath = filePath;
try {
resolvedPath = await file.resolveSymbolicLinks();
if (resolvedPath != normalizedPath) {
processedPaths.add(p.normalize(resolvedPath));
}
} catch (_) {}
if (!await file.exists()) return [];
rawContent = await file.readAsString();
} catch (_) {
return [];
}
// parse frontmatter
final parsed = parseFrontmatter(rawContent);
final globs = _parseFrontmatterPaths(parsed.frontmatter);
// strip HTML block comments
final stripped = stripHtmlComments(parsed.content);
if (stripped.trim().isEmpty) return [];
final memFile = MemoryFileInfo(
path: filePath,
type: type,
content: stripped.trim(),
parent: parent,
globs: globs,
);
final result = <MemoryFileInfo>[memFile];
// process @include directives
final fileDir = p.dirname(filePath);
final includePaths = _extractIncludePaths(stripped, fileDir);
for (final includePath in includePaths) {
final isExternal = originalCwd != null &&
!_pathIsUnder(includePath, originalCwd);
if (isExternal && !includeExternal) continue;
final included = await processMemoryFile(
includePath,
type,
processedPaths,
includeExternal: includeExternal,
depth: depth + 1,
parent: filePath,
originalCwd: originalCwd,
);
result.addAll(included);
}
return result;
}
bool _pathIsUnder(String path, String root) {
final normPath = p.normalize(path);
final normRoot = p.normalize(root);
return normPath.startsWith(normRoot + p.separator) || normPath == normRoot;
}
// ──────────────────────────────────────────────────────────────────────────────
// rules directory processor
// ──────────────────────────────────────────────────────────────────────────────
// Processes all .md files in a .claude/rules/ directory (and subdirectories).
// conditionalRule=false → keep files WITHOUT a paths: frontmatter
// conditionalRule=true → keep files WITH a paths: frontmatter
Future<List<MemoryFileInfo>> processMdRules(
String rulesDir,
MemoryType type,
Set<String> processedPaths, {
bool includeExternal = false,
bool conditionalRule = false,
Set<String>? visitedDirs,
String? originalCwd,
}) async {
visitedDirs ??= {};
String resolvedDir;
try {
resolvedDir = await Directory(rulesDir).resolveSymbolicLinks();
} catch (_) {
resolvedDir = rulesDir;
}
if (visitedDirs.contains(resolvedDir)) return [];
visitedDirs.add(resolvedDir);
final dir = Directory(resolvedDir);
List<FileSystemEntity> entries;
try {
entries = dir.listSync();
} catch (_) {
return [];
}
entries.sort((a, b) => a.path.compareTo(b.path));
final result = <MemoryFileInfo>[];
for (final entry in entries) {
if (entry is Directory) {
result.addAll(await processMdRules(
entry.path,
type,
processedPaths,
includeExternal: includeExternal,
conditionalRule: conditionalRule,
visitedDirs: visitedDirs,
originalCwd: originalCwd,
));
} else if (entry is File && entry.path.endsWith(".md")) {
String resolvedEntry;
try {
resolvedEntry = await entry.resolveSymbolicLinks();
} catch (_) {
resolvedEntry = entry.path;
}
final files = await processMemoryFile(
resolvedEntry,
type,
processedPaths,
includeExternal: includeExternal,
originalCwd: originalCwd,
);
// filter by conditional/non-conditional
result.addAll(files.where((f) => conditionalRule ? f.globs != null : f.globs == null));
}
}
return result;
}
// ──────────────────────────────────────────────────────────────────────────────
// Git root detection (for nested worktree handling)
// ──────────────────────────────────────────────────────────────────────────────
String? _findGitRoot(String startDir) {
var current = p.normalize(startDir);
final root = p.rootPrefix(current);
while (true) {
final gitDir = Directory(p.join(current, ".git"));
if (gitDir.existsSync()) return current;
final parent = p.dirname(current);
if (parent == current || current == root) return null;
current = parent;
}
}
// In a git worktree, .git is a file (not a dir) containing the gitdir path.
// The canonical root is the main repo that owns the worktree.
String? _findCanonicalGitRoot(String startDir) {
var current = p.normalize(startDir);
final root = p.rootPrefix(current);
while (true) {
final gitEntity = p.join(current, ".git");
final gitFile = File(gitEntity);
final gitDir = Directory(gitEntity);
if (gitFile.existsSync() && !gitDir.existsSync()) {
// worktree: .git is a file like "gitdir: ../../.git/worktrees/name"
try {
final content = gitFile.readAsStringSync().trim();
if (content.startsWith("gitdir:")) {
final gitdirPath = content.substring("gitdir:".length).trim();
final resolved = p.normalize(p.isAbsolute(gitdirPath)
? gitdirPath
: p.join(current, gitdirPath));
// go up from .git/worktrees/<name> → main repo root
final mainGit = p.dirname(p.dirname(p.dirname(resolved)));
return mainGit;
}
} catch (_) {}
}
if (gitDir.existsSync()) return current;
final parent = p.dirname(current);
if (parent == current || current == root) return null;
current = parent;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Main entry point
// ──────────────────────────────────────────────────────────────────────────────
// Loads all CLAUDE.md files in the same order as Claude Code.
// Returns a list of MemoryFileInfo objects, later entries have higher priority.
Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
final result = <MemoryFileInfo>[];
final processedPaths = <String>{};
final originalCwd = workingDirectory != null ? p.normalize(workingDirectory) : null;
// 1. Managed memory
final managedDir = _getManagedFilePath();
final managedMd = p.join(managedDir, "CLAUDE.md");
result.addAll(await processMemoryFile(
managedMd,
MemoryType.managed,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
result.addAll(await processMdRules(
p.join(managedDir, ".claude", "rules"),
MemoryType.managed,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
// 2. User memory
final userConfigDir = _getClaudeConfigHomeDir();
final userMd = p.join(userConfigDir, "CLAUDE.md");
result.addAll(await processMemoryFile(
userMd,
MemoryType.user,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
result.addAll(await processMdRules(
p.join(userConfigDir, "rules"),
MemoryType.user,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
if (originalCwd != null) {
// 3 & 4. Project + Local memory — walk up from cwd to root
final dirs = _buildAncestorChain(originalCwd);
// git worktree detection — same logic as Claude Code
final gitRoot = _findGitRoot(originalCwd);
final canonicalRoot = _findCanonicalGitRoot(originalCwd);
final isNestedWorktree = gitRoot != null &&
canonicalRoot != null &&
p.normalize(gitRoot) != p.normalize(canonicalRoot) &&
_pathIsUnder(gitRoot, canonicalRoot);
// process from root → cwd (so cwd files are loaded last = highest priority)
for (final dir in dirs) {
final skipProject = isNestedWorktree &&
_pathIsUnder(dir, canonicalRoot!) &&
!_pathIsUnder(dir, gitRoot!);
if (!skipProject) {
// CLAUDE.md
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
// .claude/CLAUDE.md
result.addAll(await processMemoryFile(
p.join(dir, ".claude", "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
// .claude/rules/*.md
result.addAll(await processMdRules(
p.join(dir, ".claude", "rules"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
}
// CLAUDE.local.md (not skipped even in nested worktrees)
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.local.md"),
MemoryType.local,
processedPaths,
originalCwd: originalCwd,
));
}
// env var for additional directories
final additionalDirsEnv =
Platform.environment["CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD"];
if (additionalDirsEnv != null && additionalDirsEnv.isNotEmpty &&
_isEnvTruthy(additionalDirsEnv)) {
final additionalDirs = _getAdditionalDirs();
for (final dir in additionalDirs) {
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
result.addAll(await processMemoryFile(
p.join(dir, ".claude", "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
result.addAll(await processMdRules(
p.join(dir, ".claude", "rules"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
}
}
}
return result;
}
// Builds ancestor chain from filesystem root down to dir (inclusive)
List<String> _buildAncestorChain(String dir) {
final chain = <String>[];
var current = p.normalize(dir);
final root = p.rootPrefix(current);
while (true) {
chain.add(current);
final parent = p.dirname(current);
if (parent == current || current == root) break;
current = parent;
}
return chain.reversed.toList();
}
bool _isEnvTruthy(String value) {
final v = value.toLowerCase().trim();
return v == "1" || v == "true" || v == "yes";
}
List<String> _getAdditionalDirs() {
// Claude Code gets these from bootstrap state (--add-dir flag).
// The Agency doesn't support --add-dir yet, so return empty.
return [];
}
// ──────────────────────────────────────────────────────────────────────────────
// Assembler (mirrors getClaudeMds)
// ──────────────────────────────────────────────────────────────────────────────
// Assembles memory files into a single string for injection into the system prompt.
// Mirrors getClaudeMds() from Claude Code.
String getClaudeMds(List<MemoryFileInfo> memoryFiles) {
final memories = <String>[];
for (final file in memoryFiles) {
final content = file.content.trim();
if (content.isEmpty) continue;
memories.add("Contents of ${file.path}${file.type.label}:\n\n$content");
}
if (memories.isEmpty) return "";
return "$_memoryInstructionPrompt\n\n${memories.join("\n\n")}";
}
@@ -0,0 +1,98 @@
import "package:yaml/yaml.dart";
class ParsedFrontmatter {
final Map<String, dynamic> frontmatter;
final String content;
const ParsedFrontmatter({required this.frontmatter, required this.content});
}
final _frontmatterRegex = RegExp(r"^---\s*\n([\s\S]*?)---\s*\n?");
ParsedFrontmatter parseFrontmatter(String markdown) {
final match = _frontmatterRegex.firstMatch(markdown);
if (match == null) {
return ParsedFrontmatter(frontmatter: {}, content: markdown);
}
final frontmatterText = match.group(1) ?? "";
final content = markdown.substring(match.end);
Map<String, dynamic> frontmatter = {};
try {
final parsed = loadYaml(frontmatterText);
if (parsed is YamlMap) {
frontmatter = _deepConvert(parsed) as Map<String, dynamic>;
}
} catch (_) {}
return ParsedFrontmatter(frontmatter: frontmatter, content: content);
}
dynamic _deepConvert(dynamic value) {
if (value is YamlMap) {
return {
for (final entry in value.entries)
entry.key.toString(): _deepConvert(entry.value),
};
}
if (value is YamlList) {
return [for (final item in value) _deepConvert(item)];
}
return value;
}
// Splits the frontmatter `paths:` value into individual glob patterns.
// Accepts a comma-separated string or a list.
// Handles brace expansion: src/*.{ts,tsx} → [src/*.ts, src/*.tsx]
List<String> splitPathInFrontmatter(dynamic input) {
if (input is List) {
return input.expand((e) => splitPathInFrontmatter(e.toString())).toList();
}
if (input is! String) return [];
// split by comma while respecting braces
final parts = <String>[];
var current = StringBuffer();
var braceDepth = 0;
for (var i = 0; i < input.length; i++) {
final char = input[i];
if (char == "{") {
braceDepth++;
current.write(char);
} else if (char == "}") {
braceDepth--;
current.write(char);
} else if (char == "," && braceDepth == 0) {
final trimmed = current.toString().trim();
if (trimmed.isNotEmpty) parts.add(trimmed);
current.clear();
} else {
current.write(char);
}
}
final last = current.toString().trim();
if (last.isNotEmpty) parts.add(last);
return parts
.where((p) => p.isNotEmpty)
.expand(_expandBraces)
.toList();
}
List<String> _expandBraces(String pattern) {
final braceMatch = RegExp(r"^([^{]*)\{([^}]+)\}(.*)$").firstMatch(pattern);
if (braceMatch == null) return [pattern];
final prefix = braceMatch.group(1) ?? "";
final alternatives = braceMatch.group(2) ?? "";
final suffix = braceMatch.group(3) ?? "";
return alternatives
.split(",")
.map((alt) => alt.trim())
.expand((alt) => _expandBraces("$prefix$alt$suffix"))
.toList();
}
@@ -0,0 +1,22 @@
import "memory_types.dart";
class MemoryFileInfo {
final String path;
final MemoryType type;
final String content;
final String? parent;
// glob patterns from frontmatter `paths:` field
// null means no paths restriction (applies to all files)
final List<String>? globs;
const MemoryFileInfo({
required this.path,
required this.type,
required this.content,
this.parent,
this.globs,
});
}
+16
View File
@@ -0,0 +1,16 @@
enum MemoryType { managed, user, project, local }
extension MemoryTypeDescription on MemoryType {
String get label {
switch (this) {
case MemoryType.managed:
return " (user's private global instructions for all projects)";
case MemoryType.user:
return " (user's private global instructions for all projects)";
case MemoryType.project:
return " (project instructions, checked into the codebase)";
case MemoryType.local:
return " (user's private project instructions, not checked in)";
}
}
}
@@ -1,20 +1,27 @@
String buildDefaultSystemPrompt({
String? appendSystemPrompt,
String? customSystemPrompt,
String? claudeMd,
}) {
if (customSystemPrompt != null && customSystemPrompt.trim().isNotEmpty) {
final parts = <String>[customSystemPrompt];
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
parts.add(appendSystemPrompt);
}
if (claudeMd != null && claudeMd.trim().isNotEmpty) {
parts.add(claudeMd);
}
return parts.join("\n\n");
}
final parts = <String>[
"You are Claude Code, an AI assistant for software engineering.",
"You are The Agency, an AI assistant for software engineering.",
];
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
parts.add(appendSystemPrompt);
}
if (claudeMd != null && claudeMd.trim().isNotEmpty) {
parts.add(claudeMd);
}
return parts.join("\n\n");
}
+48
View File
@@ -0,0 +1,48 @@
import "base_tool.dart";
/// A tool for spawning and coordinating AI agents for collaborative work
class AgentTool extends BaseTool {
@override
final String name = "Agent";
@override
final String description = "Spawn and coordinate AI agents for collaborative work";
@override
Future<String> execute(Map<String, dynamic> input) async {
final agentType = input["agent_type"] as String? ?? "general-purpose";
final task = input["task"] as String? ?? "general task";
final modelName = input["model"] as String?;
final teamName = input["team_name"] as String?;
final agentId = 'agent_${DateTime.now().millisecondsSinceEpoch}';
// Different agent types with different response patterns
final responses = <String, String>{
'general-purpose': "I've analyzed the task '$task' and here's my comprehensive response...",
'researcher': "After researching '$task', I found several key insights about this topic...",
'tester': "I've tested the implementation for '$task' and found a few issues that need addressing...",
'reviewer': "Here's my code review for '$task' with suggestions for improvement...",
'planner': "I've created a detailed plan for '$task' with the following steps...",
};
final result = responses[agentType] ?? "Agent completed the task successfully.";
final buffer = StringBuffer();
buffer.writeln('Agent $agentId ($agentType) has been spawned.');
if (modelName != null) {
buffer.writeln('Model: $modelName');
}
if (teamName != null) {
buffer.writeln('Team: $teamName');
}
buffer.writeln();
buffer.writeln('Task: $task');
buffer.writeln();
buffer.writeln(result);
buffer.writeln();
buffer.writeln('Note: In a full implementation, this would spawn an actual AI agent.');
return buffer.toString();
}
}
+198
View File
@@ -0,0 +1,198 @@
import 'dart:io';
import '../services/task_executor.dart';
import 'base_tool.dart';
/// Tool for executing background tasks with real process management
class ExecuteTaskTool extends BaseTool {
@override
final String name = 'ExecuteTask';
@override
final String description =
'Execute a task as a background process and get real-time status';
final TaskExecutor _executor = TaskExecutor();
@override
Future<String> execute(Map<String, dynamic> input) async {
final action = input['action'] as String? ?? 'execute';
final taskId = input['task_id'] as String?;
final command = input['command'] as String?;
final arguments = _readStringList(input['arguments']);
final processId = input['process_id'] as String?;
final workingDirectory = input['working_directory'] as String? ??
Directory.current.path;
switch (action.toLowerCase()) {
case 'execute':
if (taskId == null || command == null) {
return 'Error: task_id and command are required for execute action';
}
return await _executeTask(
taskId: taskId,
command: command,
arguments: arguments,
workingDirectory: workingDirectory,
);
case 'status':
if (processId == null) {
return 'Error: process_id is required for status action';
}
return _getStatus(processId);
case 'result':
if (processId == null) {
return 'Error: process_id is required for result action';
}
return _getResult(processId);
case 'cancel':
if (processId == null) {
return 'Error: process_id is required for cancel action';
}
final force = input['force'] as bool? ?? false;
return await _cancelTask(processId, force: force);
case 'list':
return _listActiveTasks();
default:
return 'Error: Unknown action "$action". Available actions: execute, status, result, cancel, list';
}
}
Future<String> _executeTask({
required String taskId,
required String command,
required List<String> arguments,
required String workingDirectory,
}) async {
try {
final processId = await _executor.executeTask(
taskId: taskId,
command: command,
arguments: arguments,
workingDirectory: workingDirectory,
);
return '''Executing task: $taskId
Process ID: $processId
Command: $command ${arguments.join(' ')}
Working directory: $workingDirectory
Use ExecuteTask:status process_id="$processId" to check status
Use ExecuteTask:result process_id="$processId" to get results
Use ExecuteTask:cancel process_id="$processId" to stop''';
} catch (e) {
return 'Error: ${e.toString()}';
}
}
String _getStatus(String processId) {
final status = _executor.getTaskStatus(processId);
final tasks = _executor.getActiveTasks();
final taskInfo = tasks.firstWhere(
(t) => t['id'] == processId,
orElse: () => <String, dynamic>{},
);
if (taskInfo.isEmpty) {
return 'Task not found or already completed';
}
final buffer = StringBuffer();
buffer.writeln('Process: $processId');
buffer.writeln('Status: ${status.name}');
buffer.writeln('Command: ${taskInfo['command']}');
buffer.writeln('Duration: ${taskInfo['duration_ms']}ms');
buffer.writeln('Output lines: ${taskInfo['output_lines']}');
buffer.writeln('Error lines: ${taskInfo['error_lines']}');
if (status == TaskExecutionStatus.completed ||
status == TaskExecutionStatus.failed) {
buffer.writeln('Exit code: ${taskInfo['exit_code']}');
buffer.writeln('\nUse ExecuteTask:result process_id="$processId" to get full output');
}
return buffer.toString();
}
String _getResult(String processId) {
final result = _executor.getResult(processId);
if (result == null) {
return 'Task still running. Use ExecuteTask:status process_id="$processId"';
}
final buffer = StringBuffer();
buffer.writeln('Process: $processId');
buffer.writeln('Status: ${result.status.name}');
buffer.writeln('Exit code: ${result.exitCode}');
buffer.writeln('Duration: ${result.duration?.inSeconds}s');
buffer.writeln();
if (result.output.isNotEmpty) {
buffer.writeln('--- OUTPUT ---');
buffer.writeln(result.output);
}
if (result.errors.isNotEmpty) {
buffer.writeln();
buffer.writeln('--- ERRORS ---');
buffer.writeln(result.errors);
}
return buffer.toString();
}
Future<String> _cancelTask(String processId, {required bool force}) async {
final success = await _executor.cancelTask(processId, force: force);
if (success) {
return 'Task $processId cancelled successfully';
} else {
return 'Error: Could not cancel task $processId';
}
}
String _listActiveTasks() {
final tasks = _executor.getActiveTasks();
if (tasks.isEmpty) {
return 'No active tasks';
}
final buffer = StringBuffer();
buffer.writeln('Active tasks (${tasks.length}):');
buffer.writeln('' * 60);
for (final task in tasks) {
final id = task['id'] as String;
final command = task['command'] as String;
final taskId = task['task_id'] as String;
final durationMs = task['duration_ms'] as int?;
buffer.writeln('$id (Task: $taskId)');
buffer.writeln(' Command: $command');
buffer.writeln(' Duration: ${durationMs ?? 0}ms');
buffer.writeln();
}
return buffer.toString();
}
List<String> _readStringList(Object? value) {
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
}
+97 -45
View File
@@ -102,6 +102,7 @@ class GrepTool extends BaseTool {
int? contextAfter,
}) async {
final searchPath = path ?? Directory.current.path;
final searchPathType = FileSystemEntity.typeSync(searchPath, followLinks: true);
final args = <String>["--hidden"];
@@ -121,6 +122,10 @@ class GrepTool extends BaseTool {
}
if (showLineNumbers && outputMode == "content") args.add("-n");
if (searchPathType == FileSystemEntityType.file &&
outputMode != "files_with_matches") {
args.add("--with-filename");
}
if (outputMode == "content") {
if (contextLines != null) {
@@ -175,59 +180,47 @@ class GrepTool extends BaseTool {
int? headLimit,
required int offset,
}) async {
final searchDir = Directory(path ?? Directory.current.path);
if (!await searchDir.exists()) {
return "Error: Path does not exist: ${searchDir.path}";
final searchPath = path ?? Directory.current.path;
final entityType = FileSystemEntity.typeSync(searchPath, followLinks: true);
if (entityType == FileSystemEntityType.notFound) {
return "Error: Path does not exist: $searchPath";
}
final regex = RegExp(pattern, caseSensitive: !caseInsensitive, multiLine: true);
final matchedFiles = <String>[];
final contentLines = <String>[];
var totalMatches = 0;
final baseDir = entityType == FileSystemEntityType.directory
? searchPath
: File(searchPath).parent.path;
await for (final entity in searchDir.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
if (entityType == FileSystemEntityType.file) {
await _searchFile(
file: File(searchPath),
regex: regex,
glob: glob,
outputMode: outputMode,
showLineNumbers: showLineNumbers,
baseDir: baseDir,
matchedFiles: matchedFiles,
contentLines: contentLines,
);
} else {
final searchDir = Directory(searchPath);
await for (final entity
in searchDir.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
// skip vcs dirs
final parts = entity.path.split("/");
if (parts.any((p) => _vcsSkip.contains(p))) continue;
// glob filter
if (glob != null) {
final filename = entity.path.split("/").last;
if (!_simpleGlobMatch(glob, filename)) continue;
}
String content;
try {
content = await entity.readAsString(encoding: utf8);
} catch (_) {
continue; // skip binary/unreadable
}
final fileMatches = regex.allMatches(content).length;
if (fileMatches == 0) continue;
final relPath = entity.path.startsWith(searchDir.path)
? entity.path.substring(searchDir.path.length + 1)
: entity.path;
if (outputMode == "files_with_matches") {
matchedFiles.add(relPath);
} else if (outputMode == "count") {
contentLines.add("$relPath:$fileMatches");
totalMatches += fileMatches;
} else {
// content mode
final fileLines = content.split("\n");
for (var i = 0; i < fileLines.length; i++) {
if (regex.hasMatch(fileLines[i])) {
final prefix = showLineNumbers ? "$relPath:${i + 1}:" : "$relPath:";
contentLines.add("$prefix${fileLines[i]}");
}
}
matchedFiles.add(relPath);
await _searchFile(
file: entity,
regex: regex,
glob: glob,
outputMode: outputMode,
showLineNumbers: showLineNumbers,
baseDir: baseDir,
matchedFiles: matchedFiles,
contentLines: contentLines,
);
}
}
@@ -238,6 +231,65 @@ class GrepTool extends BaseTool {
return _formatResults(contentLines, outputMode, headLimit, offset);
}
Future<void> _searchFile({
required File file,
required RegExp regex,
required String? glob,
required String outputMode,
required bool showLineNumbers,
required String baseDir,
required List<String> matchedFiles,
required List<String> contentLines,
}) async {
final parts = file.path.split("/");
if (parts.any((p) => _vcsSkip.contains(p))) return;
if (glob != null) {
final filename = file.path.split("/").last;
if (!_simpleGlobMatch(glob, filename)) return;
}
String content;
try {
content = await file.readAsString(encoding: utf8);
} catch (_) {
return;
}
final fileMatches = regex.allMatches(content).length;
if (fileMatches == 0) return;
final relPath = _displayPath(file.path, baseDir);
if (outputMode == "files_with_matches") {
matchedFiles.add(relPath);
return;
}
if (outputMode == "count") {
contentLines.add("$relPath:$fileMatches");
return;
}
final fileLines = content.split("\n");
for (var i = 0; i < fileLines.length; i++) {
if (regex.hasMatch(fileLines[i])) {
final prefix = showLineNumbers ? "$relPath:${i + 1}:" : "$relPath:";
contentLines.add("$prefix${fileLines[i]}");
}
}
}
String _displayPath(String filePath, String baseDir) {
if (filePath == baseDir) {
return filePath.split("/").last;
}
if (filePath.startsWith("$baseDir/")) {
return filePath.substring(baseDir.length + 1);
}
return filePath;
}
String _formatResults(List<String> lines, String outputMode, int? headLimit, int offset) {
// apply offset + head_limit
+241
View File
@@ -0,0 +1,241 @@
import 'dart:convert';
import 'base_tool.dart';
/// Tool for interacting with Model Context Protocol (MCP) servers
class McpTool extends BaseTool {
@override
final String name = 'MCP';
@override
final String description =
'Interact with Model Context Protocol (MCP) servers and resources';
@override
Future<String> execute(Map<String, dynamic> input) async {
final action = input['action'] as String? ?? 'list';
final serverName = input['server_name'] as String?;
final resourceUri = input['resource_uri'] as String?;
final command = input['command'] as String?;
switch (action.toLowerCase()) {
case 'list':
return _listServers();
case 'resources':
if (serverName == null) {
return 'Error: server_name is required for resources action';
}
return await _listResources(serverName);
case 'read':
if (serverName == null || resourceUri == null) {
return 'Error: server_name and resource_uri are required for read action';
}
return await _readResource(serverName, resourceUri);
case 'connect':
if (serverName == null || command == null) {
return 'Error: server_name and command are required for connect action';
}
return await _connectServer(serverName, command);
case 'disconnect':
if (serverName == null) {
return 'Error: server_name is required for disconnect action';
}
return await _disconnectServer(serverName);
case 'info':
if (serverName == null) {
return 'Error: server_name is required for info action';
}
return await _serverInfo(serverName);
default:
return 'Error: Unknown action "$action". Available actions: list, resources, read, connect, disconnect, info';
}
}
String _listServers() {
// In a real implementation, this would read from config
final servers = <Map<String, dynamic>>[
{
'name': 'filesystem',
'status': 'connected',
'type': 'builtin',
'description': 'Access to local filesystem',
},
{
'name': 'git',
'status': 'available',
'type': 'builtin',
'description': 'Git repository operations',
},
{
'name': 'clock',
'status': 'connected',
'type': 'example',
'description': 'Current time and date information',
},
];
final buffer = StringBuffer();
buffer.writeln('MCP Servers (${servers.length}):');
buffer.writeln('' * 60);
for (final server in servers) {
final status = server['status'] as String;
final statusSymbol = status == 'connected' ? '' : '';
buffer.write('$statusSymbol ${server['name']}');
buffer.write(' (${server['type']})');
buffer.writeln(' - ${server['description']}');
}
buffer.writeln();
buffer.writeln('Use MCP:resources server_name="filesystem" to list available resources');
buffer.writeln('Use MCP:connect server_name="new-server" command="npx @modelcontextprotocol/server-filesystem" to add a server');
return buffer.toString();
}
Future<String> _listResources(String serverName) async {
// Mock resources based on server name
final resources = <Map<String, String>>[];
switch (serverName.toLowerCase()) {
case 'filesystem':
resources.addAll([
{'uri': 'file:///README.md', 'name': 'README.md', 'type': 'file'},
{'uri': 'file:///lib/', 'name': 'lib directory', 'type': 'directory'},
{'uri': 'file:///pubspec.yaml', 'name': 'pubspec.yaml', 'type': 'file'},
]);
break;
case 'git':
resources.addAll([
{'uri': 'git:///status', 'name': 'Git Status', 'type': 'status'},
{'uri': 'git:///log', 'name': 'Git Log', 'type': 'log'},
{'uri': 'git:///diff', 'name': 'Git Diff', 'type': 'diff'},
]);
break;
case 'clock':
resources.addAll([
{'uri': 'clock:///now', 'name': 'Current Time', 'type': 'time'},
{'uri': 'clock:///date', 'name': 'Current Date', 'type': 'date'},
{'uri': 'clock:///timezone', 'name': 'Timezone Info', 'type': 'timezone'},
]);
break;
default:
return 'Error: Server "$serverName" not found or has no resources';
}
final buffer = StringBuffer();
buffer.writeln('Resources for $serverName (${resources.length}):');
buffer.writeln('' * 60);
for (final resource in resources) {
buffer.write('${resource['uri']}');
buffer.write(' (${resource['type']})');
buffer.writeln(' - ${resource['name']}');
}
buffer.writeln();
buffer.writeln('Read a resource with: MCP:read server_name="$serverName" resource_uri="${resources.first['uri']}"');
return buffer.toString();
}
Future<String> _readResource(String serverName, String resourceUri) async {
// Mock resource content based on URI
final now = DateTime.now();
String content;
switch (resourceUri) {
case 'file:///README.md':
content = '# Project README\n\nThis is a sample README file.';
break;
case 'file:///pubspec.yaml':
content = 'name: clawd_code\ndescription: Claude Code Dart CLI\nversion: 1.0.0';
break;
case 'clock:///now':
content = now.toIso8601String();
break;
case 'clock:///date':
content = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
break;
case 'git:///status':
content = 'On branch main\nnothing to commit, working tree clean';
break;
default:
if (resourceUri.startsWith('file:///')) {
final path = resourceUri.substring(7);
content = 'File content for $path\n\nThis is simulated MCP file content.';
} else {
content = 'Resource content for $resourceUri\n\nThis is simulated MCP resource data.';
}
}
final buffer = StringBuffer();
buffer.writeln('Resource: $resourceUri');
buffer.writeln('Server: $serverName');
buffer.writeln('' * 60);
buffer.writeln(content);
buffer.writeln('' * 60);
buffer.writeln();
buffer.writeln('Note: This is simulated MCP resource data. In a real implementation,');
buffer.writeln('this would connect to an actual MCP server.');
return buffer.toString();
}
Future<String> _connectServer(String serverName, String command) async {
final buffer = StringBuffer();
buffer.writeln('Connecting MCP server: $serverName');
buffer.writeln('Command: $command');
buffer.writeln();
buffer.writeln('In a real implementation, this would:');
buffer.writeln('1. Start the MCP server process');
buffer.writeln('2. Establish WebSocket connection');
buffer.writeln('3. Initialize protocol handshake');
buffer.writeln('4. Load available tools and resources');
buffer.writeln();
buffer.writeln('Simulated server connected successfully.');
buffer.writeln();
buffer.writeln('Use MCP:resources server_name="$serverName" to see available resources');
buffer.writeln('Use MCP:info server_name="$serverName" to see server details');
return buffer.toString();
}
Future<String> _disconnectServer(String serverName) async {
return '''Disconnecting MCP server: $serverName
In a real implementation, this would:
1. Send shutdown signal to server
2. Close WebSocket connection
3. Clean up resources
Simulated server disconnected successfully.''';
}
Future<String> _serverInfo(String serverName) async {
final info = <String, dynamic>{
'name': serverName,
'protocol': 'MCP 2024-11-05',
'version': '1.0.0',
'capabilities': ['resources', 'tools', 'prompts'],
'status': 'connected',
'uptime': '5m 23s',
'resources_count': 3,
'tools_count': 2,
};
final buffer = StringBuffer();
buffer.writeln('MCP Server Info: $serverName');
buffer.writeln('' * 40);
for (final entry in info.entries) {
if (entry.value is List) {
buffer.writeln('${entry.key}: ${(entry.value as List).join(', ')}');
} else {
buffer.writeln('${entry.key}: ${entry.value}');
}
}
return buffer.toString();
}
}
+88
View File
@@ -0,0 +1,88 @@
import "base_tool.dart";
/// A basic agent tool for demonstration and simple agent operations
class SimpleAgentTool extends BaseTool {
@override
final String name = "SimpleAgent";
@override
final String description = "A basic agent tool for demonstration and simple agent management";
@override
Future<String> execute(Map<String, dynamic> input) async {
final action = input["action"] as String? ?? "info";
switch (action.toLowerCase()) {
case "info":
return _agentInfo();
case "list":
return _listAgents();
case "status":
return _agentStatus();
case "create":
final agentName = input["name"] as String? ?? "demo_agent";
return _createAgent(agentName);
default:
return 'Unknown action: $action. Available actions: info, list, status, create';
}
}
String _agentInfo() {
return '''
Simple Agent Tool Info:
-----------------------
This is a demonstration agent tool for the Dart CLI migration.
In a full implementation, this would manage actual AI agents.
Features:
- Basic agent lifecycle management
- Agent status tracking
- Simple agent creation
Note: This is a placeholder implementation for migration testing.
''';
}
String _listAgents() {
return '''
Available Agents (demo):
-----------------------
1. demo_agent_1 (status: idle, type: general-purpose)
2. demo_agent_2 (status: busy, type: researcher)
3. demo_agent_3 (status: idle, type: tester)
Total: 3 demo agents
Note: These are placeholder agents for demonstration.
''';
}
String _agentStatus() {
return '''
Agent Status Summary:
--------------------
Active agents: 1
Idle agents: 2
Total agents: 3
Last activity: ${DateTime.now().subtract(const Duration(minutes: 5))}
Note: This is demo status data.
''';
}
String _createAgent(String agentName) {
final agentId = '${agentName}_${DateTime.now().millisecondsSinceEpoch ~/ 1000}';
return '''
Created new agent: $agentId
----------------------------
Name: $agentName
ID: $agentId
Status: initialized
Type: general-purpose
Created: ${DateTime.now()}
Note: This is a demo agent creation.
''';
}
}
+233
View File
@@ -0,0 +1,233 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;
import 'base_tool.dart';
/// Tool for managing and executing skills
class SkillTool extends BaseTool {
@override
final String name = 'Skill';
@override
final String description =
'List, execute, and manage reusable prompt templates (skills)';
@override
Future<String> execute(Map<String, dynamic> input) async {
final action = input['action'] as String? ?? 'list';
final skillName = input['skill_name'] as String?;
final content = input['content'] as String?;
final params = input['params'] as Map<String, dynamic>? ?? {};
switch (action.toLowerCase()) {
case 'list':
return _listSkills();
case 'execute':
if (skillName == null) {
return 'Error: skill_name is required for execute action';
}
return await _executeSkill(skillName, params);
case 'info':
if (skillName == null) {
return 'Error: skill_name is required for info action';
}
return await _getSkillInfo(skillName);
case 'create':
if (skillName == null || content == null) {
return 'Error: skill_name and content are required for create action';
}
return await _createSkill(skillName, content);
default:
return 'Error: Unknown action "$action". Available actions: list, execute, info, create';
}
}
Future<String> _listSkills() async {
final skillsDir = await _getSkillsDirectory();
final skills = <String>[];
if (await skillsDir.exists()) {
final files = skillsDir.listSync();
for (final file in files) {
if (file is File && file.path.endsWith('.md')) {
skills.add(path.basenameWithoutExtension(file.path));
}
}
}
if (skills.isEmpty) {
return '''No skills found.
Skills are reusable prompt templates stored as .md files in:
${skillsDir.path}
Create a skill with Skill:create skill_name="my-skill" content="# My Skill\\n\\nThis skill does something useful."''';
}
final buffer = StringBuffer();
buffer.writeln('Available skills (${skills.length}):');
buffer.writeln('' * 40);
skills.sort();
for (final skill in skills) {
buffer.writeln('$skill');
}
buffer.writeln();
buffer.writeln('Execute a skill with: Skill:execute skill_name="$skills.first"');
return buffer.toString();
}
Future<String> _executeSkill(
String skillName, Map<String, dynamic> params) async {
final skillContent = await _loadSkill(skillName);
if (skillContent.isEmpty) {
return 'Error: Skill "$skillName" not found';
}
// Replace template variables
var result = skillContent;
for (final entry in params.entries) {
result = result.replaceAll('{{${entry.key}}}', entry.value.toString());
}
final buffer = StringBuffer();
buffer.writeln('Executing skill: $skillName');
buffer.writeln('' * 40);
buffer.writeln();
// Parse skill metadata
final lines = skillContent.split('\n');
var inMetadata = false;
var description = '';
for (final line in lines) {
if (line.startsWith('---')) {
inMetadata = !inMetadata;
continue;
}
if (inMetadata) {
if (line.startsWith('description:')) {
description = line.substring('description:'.length).trim();
}
}
}
if (description.isNotEmpty) {
buffer.writeln('Description: $description');
buffer.writeln();
}
buffer.writeln('Skill content:');
buffer.writeln('' * 40);
buffer.writeln(result);
buffer.writeln('' * 40);
return buffer.toString();
}
Future<String> _getSkillInfo(String skillName) async {
final skillContent = await _loadSkill(skillName);
if (skillContent.isEmpty) {
return 'Error: Skill "$skillName" not found';
}
final lines = skillContent.split('\n');
final buffer = StringBuffer();
buffer.writeln('Skill: $skillName');
buffer.writeln('' * 40);
var inMetadata = false;
var hasMetadata = false;
for (final line in lines) {
if (line.startsWith('---')) {
if (!inMetadata) {
hasMetadata = true;
}
inMetadata = !inMetadata;
continue;
}
if (inMetadata) {
if (line.startsWith('description:')) {
buffer.writeln('Description: ${line.substring('description:'.length).trim()}');
} else if (line.startsWith('author:')) {
buffer.writeln('Author: ${line.substring('author:'.length).trim()}');
} else if (line.startsWith('version:')) {
buffer.writeln('Version: ${line.substring('version:'.length).trim()}');
} else if (line.startsWith('tags:')) {
buffer.writeln('Tags: ${line.substring('tags:'.length).trim()}');
}
}
}
if (!hasMetadata) {
buffer.writeln('No metadata found.');
}
buffer.writeln();
buffer.writeln('Preview (first 200 chars):');
buffer.writeln('' * 40);
final contentStart = skillContent.indexOf('---', 3) + 3;
final preview = contentStart > 3
? skillContent.substring(contentStart).trim()
: skillContent.trim();
buffer.writeln(preview.length > 200 ? '${preview.substring(0, 200)}...' : preview);
return buffer.toString();
}
Future<String> _createSkill(String skillName, String content) async {
final skillsDir = await _getSkillsDirectory();
await skillsDir.create(recursive: true);
final skillFile = File(path.join(skillsDir.path, '$skillName.md'));
// Add basic metadata if not present
var skillContent = content;
if (!content.startsWith('---')) {
skillContent = '''---
skill: $skillName
created: ${DateTime.now().toIso8601String()}
---
$content''';
}
await skillFile.writeAsString(skillContent);
return '''Created skill: $skillName
Location: ${skillFile.path}
Execute with: Skill:execute skill_name="$skillName"''';
}
Future<Directory> _getSkillsDirectory() async {
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
final claudeDir = Directory(path.join(home, '.claude', 'skills'));
return claudeDir;
}
Future<String> _loadSkill(String skillName) async {
final skillsDir = await _getSkillsDirectory();
final skillFile = File(path.join(skillsDir.path, '$skillName.md'));
if (!await skillFile.exists()) {
// Check project-local skills
final localSkillsDir = Directory(path.join(Directory.current.path, '.claude', 'skills'));
final localSkillFile = File(path.join(localSkillsDir.path, '$skillName.md'));
if (await localSkillFile.exists()) {
return await localSkillFile.readAsString();
}
return '';
}
return await skillFile.readAsString();
}
}
+254
View File
@@ -0,0 +1,254 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;
import 'base_tool.dart';
/// Tool for managing background tasks
/// Tasks are persisted to ~/.clawd_code/tasks/ directory
class TaskTool extends BaseTool {
@override
final String name = 'Task';
@override
final String description =
'Create, list, get, update, and stop background tasks';
// In-memory cache (loaded from disk)
static final Map<String, Map<String, dynamic>> _tasks = {};
static int _taskCounter = 1;
static bool _initialized = false;
@override
Future<String> execute(Map<String, dynamic> input) async {
// Initialize task storage on first use
if (!_initialized) {
await _loadTasks();
_initialized = true;
}
final action = input['action'] as String? ?? 'list';
final taskId = input['task_id'] as String?;
final taskName = input['name'] as String?;
final command = input['command'] as String?;
switch (action.toLowerCase()) {
case 'create':
return await _createTask(command: command, name: taskName);
case 'list':
return _listTasks();
case 'get':
if (taskId == null) {
return 'Error: task_id is required for get action';
}
return _getTask(taskId);
case 'update':
if (taskId == null) {
return 'Error: task_id is required for update action';
}
final status = input['status'] as String?;
final output = input['output'] as String?;
return await _updateTask(taskId, status: status, output: output);
case 'stop':
if (taskId == null) {
return 'Error: task_id is required for stop action';
}
return await _stopTask(taskId);
case 'output':
if (taskId == null) {
return 'Error: task_id is required for output action';
}
return _getTaskOutput(taskId);
default:
return 'Error: Unknown action "$action". Available actions: create, list, get, update, stop, output';
}
}
Future<String> _createTask({String? command, String? name}) async {
final taskId = 'task_${_taskCounter++}';
final now = DateTime.now().toUtc();
_tasks[taskId] = {
'id': taskId,
'name': name ?? 'Unnamed task',
'command': command ?? '',
'status': 'running',
'created_at': now.toIso8601String(),
'updated_at': now.toIso8601String(),
'output': '',
};
await _saveTasks();
return '''Created task: $taskId
Name: ${name ?? 'Unnamed task'}
Command: ${command ?? '(no command)'}
Status: running
Created: ${now.toLocal()}
Use /tasks to list tasks or Task:get task_id=$taskId to check status.''';
}
String _listTasks() {
if (_tasks.isEmpty) {
return 'No tasks found. Create one with Task:create command="your command"';
}
final buffer = StringBuffer();
buffer.writeln('Tasks (${_tasks.length}):');
buffer.writeln('' * 50);
for (final task in _tasks.values) {
final id = task['id'] as String;
final name = task['name'] as String;
final status = task['status'] as String;
final createdAt = task['created_at'] as String;
final updatedAt = task['updated_at'] as String;
final created = DateTime.parse(createdAt).toLocal();
final updated = DateTime.parse(updatedAt).toLocal();
buffer.writeln('$id: $name');
buffer.writeln(' Status: $status');
buffer.writeln(' Created: ${created.toString().substring(0, 16)}');
buffer.writeln(' Updated: ${updated.toString().substring(0, 16)}');
if (task['command'] != null && (task['command'] as String).isNotEmpty) {
buffer.writeln(' Command: ${task['command']}');
}
buffer.writeln();
}
return buffer.toString();
}
String _getTask(String taskId) {
final task = _tasks[taskId];
if (task == null) {
return 'Error: Task "$taskId" not found';
}
final buffer = StringBuffer();
buffer.writeln('Task: ${task['name']}');
buffer.writeln('ID: $taskId');
buffer.writeln('Status: ${task['status']}');
buffer.writeln('Command: ${task['command']}');
buffer.writeln('Created: ${DateTime.parse(task['created_at'] as String).toLocal()}');
buffer.writeln('Updated: ${DateTime.parse(task['updated_at'] as String).toLocal()}');
final output = task['output'] as String;
if (output.isNotEmpty) {
buffer.writeln();
buffer.writeln('Output:');
buffer.writeln(output);
}
return buffer.toString();
}
Future<String> _updateTask(String taskId, {String? status, String? output}) async {
final task = _tasks[taskId];
if (task == null) {
return 'Error: Task "$taskId" not found';
}
if (status != null) {
task['status'] = status;
}
if (output != null) {
task['output'] = output;
}
task['updated_at'] = DateTime.now().toUtc().toIso8601String();
await _saveTasks();
return 'Updated task $taskId';
}
Future<String> _stopTask(String taskId) async {
final task = _tasks[taskId];
if (task == null) {
return 'Error: Task "$taskId" not found';
}
task['status'] = 'stopped';
task['updated_at'] = DateTime.now().toUtc().toIso8601String();
await _saveTasks();
return 'Stopped task $taskId';
}
String _getTaskOutput(String taskId) {
final task = _tasks[taskId];
if (task == null) {
return 'Error: Task "$taskId" not found';
}
final output = task['output'] as String;
if (output.isEmpty) {
return 'No output recorded for task $taskId';
}
return output;
}
// Persistence: load tasks from disk
Future<void> _loadTasks() async {
try {
final dir = _getTasksDirectory();
if (!await dir.exists()) {
return;
}
_tasks.clear();
_taskCounter = 1;
final files = dir.listSync();
for (final file in files) {
if (file is! File || !file.path.endsWith('.json')) {
continue;
}
try {
final content = await file.readAsString();
final json = jsonDecode(content) as Map<String, dynamic>;
final taskId = json['id'] as String?;
if (taskId != null) {
_tasks[taskId] = json;
// Update counter
final match = RegExp(r'task_(\d+)').firstMatch(taskId);
if (match != null) {
final num = int.tryParse(match.group(1)!);
if (num != null && num >= _taskCounter) {
_taskCounter = num + 1;
}
}
}
} catch (_) {
// Skip malformed files
}
}
} catch (_) {
// If loading fails, continue with empty
}
}
// Persistence: save tasks to disk
Future<void> _saveTasks() async {
try {
final dir = _getTasksDirectory();
await dir.create(recursive: true);
for (final entry in _tasks.entries) {
final file = File(path.join(dir.path, '${entry.key}.json'));
await file.writeAsString(jsonEncode(entry.value));
}
} catch (_) {
// Silently fail - tasks stored in memory anyway
}
}
Directory _getTasksDirectory() {
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
return Directory(path.join(home, '.clawd_code', 'tasks'));
}
}
+103 -22
View File
@@ -1,43 +1,124 @@
import "base_tool.dart";
import "bash_tool.dart";
import "glob_tool.dart";
import "grep_tool.dart";
import "execute_task_tool.dart";
import "file_edit_tool.dart";
import "file_read_tool.dart";
import "file_write_tool.dart";
import "file_edit_tool.dart";
import "glob_tool.dart";
import "grep_tool.dart";
import "web_fetch_tool.dart";
import "web_search_tool.dart";
import "task_tool.dart";
import "skill_tool.dart";
import "mcp_tool.dart";
import "../permissions/permission_manager.dart";
import "../local_state.dart";
import "../services/analytics_service.dart";
import "../services/usage_tracker.dart";
// registry that holds all available tools by name
class ToolRegistry {
final Map<String, BaseTool> _tools = {};
PermissionManager? _permissionManager;
ToolRegistry() {
_register(BashTool());
_register(GlobTool());
_register(GrepTool());
_register(FileReadTool());
_register(FileWriteTool());
_register(FileEditTool());
register(BashTool());
register(ExecuteTaskTool());
register(GlobTool());
register(GrepTool());
register(FileReadTool());
register(FileEditTool());
register(FileWriteTool());
register(WebSearchTool());
register(WebFetchTool());
register(TaskTool());
register(SkillTool());
register(McpTool());
}
void _register(BaseTool tool) {
/// Set settings for permission management
void setSettings(LocalSettings settings) {
_permissionManager = PermissionManager(settings);
}
final Map<String, BaseTool> _tools = <String, BaseTool>{};
List<BaseTool> get allTools => _tools.values.toList(growable: false);
List<String> get toolNames => _tools.keys.toList(growable: false);
void register(BaseTool tool) {
_tools[tool.name] = tool;
}
BaseTool? getTool(String toolName) {
return _tools[toolName];
}
BaseTool? getTool(String name) => _tools[name];
List<BaseTool> get allTools => _tools.values.toList();
List<String> get toolNames => _tools.keys.toList();
// execute a tool by name
Future<String> execute(String toolName, Map<String, dynamic> input) async {
final tool = _tools[toolName];
final tool = getTool(toolName);
if (tool == null) {
return "Error: Unknown tool \"$toolName\". Available tools: ${toolNames.join(", ")}";
return 'Error: Unknown tool "$toolName". Available tools: ${toolNames.join(", ")}';
}
// Check permissions if manager is configured
if (_permissionManager != null) {
final permissionDecision = _permissionManager!.getPermissionDecision(toolName, input, null);
switch (permissionDecision) {
case 'denied':
return 'Permission denied: Tool "$toolName" is not allowed. '
'Update permissions with /permissions or change permission mode.';
case 'ask':
final shouldRun = await _permissionManager!.getUserConfirmation(
toolName,
null,
'Allow tool "$toolName" to run?',
);
if (!shouldRun) {
return 'Permission denied: User declined to run tool "$toolName".';
}
break;
case 'allowed':
default:
// Tool is allowed, continue
break;
}
}
// Log tool execution
await _logToolExecution(toolName, input);
return tool.execute(input);
}
Future<void> _logToolExecution(String toolName, Map<String, dynamic> input) async {
try {
final analytics = AnalyticsService();
await analytics.logTool(toolName, input: input);
final usage = UsageTracker();
await usage.trackToolExecution(toolName);
} catch (e) {
// Silently fail - analytics is optional
}
}
/// Extract tool arguments as a string for permission matching
String _extractToolArgs(Map<String, dynamic> input) {
final args = <String>[];
if (input.containsKey('input')) {
args.add(input['input'].toString());
}
if (input.containsKey('command')) {
args.add(input['command'].toString());
}
if (input.containsKey('path')) {
args.add(input['path'].toString());
}
if (input.containsKey('pattern')) {
args.add(input['pattern'].toString());
}
return args.join(' ');
}
}
+863
View File
@@ -0,0 +1,863 @@
import "dart:convert";
import "dart:io";
import "dart:typed_data";
import "package:html/dom.dart" as dom;
import "package:html/parser.dart" as html_parser;
import "package:path/path.dart" as path;
import "../api/openrouter_client.dart";
import "base_tool.dart";
const int _maxUrlLength = 2000;
const int _maxFetchBytes = 10 * 1024 * 1024;
const int _maxPromptContentChars = 100000;
const int _maxRedirects = 10;
const Duration _cacheTtl = Duration(minutes: 15);
const int _maxCacheEntries = 64;
final List<_CacheKey> _cacheOrder = <_CacheKey>[];
final Map<_CacheKey, _CacheEntry> _cache = <_CacheKey, _CacheEntry>{};
const Set<String> _preapprovedHosts = {
"platform.claude.com",
"code.claude.com",
"modelcontextprotocol.io",
"docs.python.org",
"en.cppreference.com",
"docs.oracle.com",
"learn.microsoft.com",
"developer.mozilla.org",
"go.dev",
"pkg.go.dev",
"www.php.net",
"docs.swift.org",
"kotlinlang.org",
"ruby-doc.org",
"doc.rust-lang.org",
"www.typescriptlang.org",
"react.dev",
"angular.io",
"vuejs.org",
"nextjs.org",
"expressjs.com",
"nodejs.org",
"bun.sh",
"getbootstrap.com",
"tailwindcss.com",
"redux.js.org",
"webpack.js.org",
"jestjs.io",
"reactrouter.com",
"docs.djangoproject.com",
"flask.palletsprojects.com",
"fastapi.tiangolo.com",
"pandas.pydata.org",
"numpy.org",
"www.tensorflow.org",
"pytorch.org",
"scikit-learn.org",
"matplotlib.org",
"requests.readthedocs.io",
"jupyter.org",
"laravel.com",
"symfony.com",
"wordpress.org",
"docs.spring.io",
"hibernate.org",
"tomcat.apache.org",
"gradle.org",
"maven.apache.org",
"asp.net",
"dotnet.microsoft.com",
"nuget.org",
"reactnative.dev",
"docs.flutter.dev",
"developer.apple.com",
"developer.android.com",
"keras.io",
"spark.apache.org",
"huggingface.co",
"www.kaggle.com",
"www.mongodb.com",
"redis.io",
"www.postgresql.org",
"dev.mysql.com",
"www.sqlite.org",
"graphql.org",
"prisma.io",
"docs.aws.amazon.com",
"cloud.google.com",
"kubernetes.io",
"www.docker.com",
"www.terraform.io",
"www.ansible.com",
"docs.netlify.com",
"devcenter.heroku.com",
"cypress.io",
"selenium.dev",
"docs.unity.com",
"docs.unrealengine.com",
"git-scm.com",
"nginx.org",
"httpd.apache.org",
};
const Map<String, List<String>> _preapprovedPathPrefixes = {
"github.com": <String>["/anthropics"],
"vercel.com": <String>["/docs"],
};
class WebFetchTool extends BaseTool {
@override
final String name = "WebFetch";
@override
final String description =
"Fetch content from a URL, convert it to readable markdown-like text, and answer a prompt about that page.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final rawUrl = requireString(input, "url").trim();
final prompt = requireString(input, "prompt").trim();
final apiKey = optionalString(input, "_api_key") ?? "";
final model = optionalString(input, "_model") ?? "openrouter/auto";
final permissionMode = optionalString(input, "_permission_mode") ?? "default";
final allowRules = _readStringList(input["_allow_rules"]);
final askRules = _readStringList(input["_ask_rules"]);
final denyRules = _readStringList(input["_deny_rules"]);
if (prompt.isEmpty) {
throw ArgumentError("prompt must not be empty");
}
if (apiKey.isEmpty) {
throw StateError("WebFetch requires an OpenRouter API key");
}
final url = _normalizeUrl(rawUrl);
final uri = Uri.parse(url);
_enforcePermissions(
uri: uri,
permissionMode: permissionMode,
allowRules: allowRules,
askRules: askRules,
denyRules: denyRules,
);
final isPreapproved = _isPreapprovedUrl(url);
final startTime = DateTime.now();
final fetched = await _fetchUrl(url);
final durationMs = DateTime.now().difference(startTime).inMilliseconds;
if (fetched.isRedirectNotice) {
return _formatOutput(
fetched: fetched,
durationMs: durationMs,
result: fetched.content,
);
}
final result = await _summarizeFetchedContent(
apiKey: apiKey,
model: model,
fetched: fetched,
prompt: prompt,
isPreapproved: isPreapproved,
);
return _formatOutput(
fetched: fetched,
durationMs: durationMs,
result: result,
);
}
String _normalizeUrl(String rawUrl) {
if (rawUrl.isEmpty || rawUrl.length > _maxUrlLength) {
throw ArgumentError("Invalid URL");
}
Uri uri;
try {
uri = Uri.parse(rawUrl);
} catch (_) {
throw ArgumentError("Invalid URL: $rawUrl");
}
if (!uri.hasScheme) {
throw ArgumentError("URL must include a scheme");
}
if (uri.userInfo.isNotEmpty || uri.host.isEmpty) {
throw ArgumentError("Invalid URL");
}
if (uri.scheme == "http") {
uri = uri.replace(scheme: "https");
}
if (uri.scheme != "https") {
throw ArgumentError("Only https URLs are supported");
}
final host = uri.host.toLowerCase();
if (_isLocalOrPrivateHost(host)) {
throw ArgumentError("Fetching local or private network URLs is not allowed");
}
return uri.toString();
}
void _enforcePermissions({
required Uri uri,
required String permissionMode,
required List<String> allowRules,
required List<String> askRules,
required List<String> denyRules,
}) {
final bypassModes = <String>{"bypassPermissions", "dontAsk"};
if (bypassModes.contains(permissionMode)) {
return;
}
final domainRule = "domain:${uri.host.toLowerCase()}";
final matchingDeny = denyRules.where((rule) => _matchesRule(rule, uri)).toList();
if (matchingDeny.isNotEmpty) {
throw StateError("WebFetch denied access to $domainRule.");
}
final matchingAllow = allowRules.where((rule) => _matchesRule(rule, uri)).toList();
if (matchingAllow.isNotEmpty) {
return;
}
final matchingAsk = askRules.where((rule) => _matchesRule(rule, uri)).toList();
if (matchingAsk.isNotEmpty) {
throw StateError(
"WebFetch requires permission for $domainRule. Add an allow rule to proceed.",
);
}
}
bool _matchesRule(String rawRule, Uri uri) {
var rule = rawRule.trim();
if (rule.startsWith("WebFetch(") && rule.endsWith(")")) {
rule = rule.substring("WebFetch(".length, rule.length - 1);
}
if (!rule.startsWith("domain:")) {
return false;
}
final pattern = rule.substring("domain:".length).toLowerCase();
final host = uri.host.toLowerCase();
if (pattern.isEmpty) {
return false;
}
if (pattern == host) {
return true;
}
if (pattern.startsWith("*.")) {
final suffix = pattern.substring(1);
return host.endsWith(suffix);
}
if (pattern.endsWith(".*")) {
final prefix = pattern.substring(0, pattern.length - 1);
return host.startsWith(prefix);
}
return false;
}
Future<_FetchedContent> _fetchUrl(String originalUrl) async {
_pruneCache();
final cacheKey = _CacheKey(originalUrl);
final cached = _cache[cacheKey];
if (cached != null && DateTime.now().difference(cached.fetchedAt) < _cacheTtl) {
_touchCacheEntry(cacheKey);
return cached.content;
}
final httpClient = HttpClient()..connectionTimeout = const Duration(seconds: 60);
try {
var currentUrl = Uri.parse(originalUrl);
final originalComparableHost = _stripWww(currentUrl.host);
for (var redirectCount = 0; redirectCount <= _maxRedirects; redirectCount++) {
final request = await httpClient.getUrl(currentUrl);
request.headers.set("Accept", "text/markdown, text/html, text/plain, */*");
request.headers.set("User-Agent", "clawd_code/0.1.0 (WebFetch)");
final response = await request.close().timeout(const Duration(seconds: 60));
final statusCode = response.statusCode;
final statusText = response.reasonPhrase;
final location = response.headers.value(HttpHeaders.locationHeader);
if (_isRedirect(statusCode) && location != null) {
final redirectUrl = currentUrl.resolve(location);
if (_stripWww(redirectUrl.host) != originalComparableHost) {
final redirectNotice = _FetchedContent(
finalUrl: currentUrl.toString(),
statusCode: statusCode,
reasonPhrase: statusText,
bytes: 0,
contentType: "text/plain",
content:
"REDIRECT DETECTED: The URL redirects to a different host.\n\n"
"Original URL: $currentUrl\n"
"Redirect URL: $redirectUrl\n"
"Status: $statusCode $statusText\n\n"
"To complete your request, use WebFetch again with these parameters:\n"
"- url: \"$redirectUrl\"",
isRedirectNotice: true,
);
_storeCacheEntry(cacheKey, redirectNotice);
return redirectNotice;
}
currentUrl = redirectUrl;
continue;
}
final bytes = await _readResponseBytes(response);
final contentType =
response.headers.contentType?.mimeType ?? "application/octet-stream";
final isBinary = _looksBinary(contentType, bytes);
final persistedBinary = isBinary
? await _persistBinaryContent(bytes, contentType)
: null;
final decodedText = _decodeBody(bytes, isBinary: isBinary);
final readableContent = _extractReadableContent(
decodedText,
contentType: contentType,
url: currentUrl.toString(),
);
final fetched = _FetchedContent(
finalUrl: currentUrl.toString(),
statusCode: statusCode,
reasonPhrase: statusText,
bytes: bytes.length,
contentType: contentType,
content: readableContent,
persistedBinaryPath: persistedBinary?.path,
persistedBinarySize: persistedBinary?.size,
);
_storeCacheEntry(cacheKey, fetched);
return fetched;
}
throw StateError("Too many redirects");
} finally {
httpClient.close();
}
}
Future<List<int>> _readResponseBytes(HttpClientResponse response) async {
final builder = BytesBuilder(copy: false);
await for (final chunk in response) {
builder.add(chunk);
if (builder.length > _maxFetchBytes) {
throw StateError("Response exceeded ${_maxFetchBytes} bytes");
}
}
return builder.takeBytes();
}
String _decodeBody(List<int> bytes, {required bool isBinary}) {
if (isBinary) {
return latin1.decode(bytes, allowInvalid: true);
}
try {
return utf8.decode(bytes);
} catch (_) {
return latin1.decode(bytes, allowInvalid: true);
}
}
Future<_PersistedBinary?> _persistBinaryContent(
List<int> bytes,
String contentType,
) async {
try {
final extension = _extensionForMimeType(contentType);
final fileName =
"webfetch-${DateTime.now().millisecondsSinceEpoch}-${_randomSuffix()}$extension";
final file = File(path.join(Directory.systemTemp.path, fileName));
await file.writeAsBytes(bytes, flush: true);
return _PersistedBinary(path: file.path, size: bytes.length);
} catch (_) {
return null;
}
}
String _extractReadableContent(
String rawContent, {
required String contentType,
required String url,
}) {
if (contentType.contains("markdown") || contentType.contains("plain")) {
return _truncateContent(rawContent.trim());
}
final document = html_parser.parse(rawContent);
document.querySelectorAll("script,style,noscript,svg,iframe").forEach((node) {
node.remove();
});
final title = document.querySelector("title")?.text.trim();
final description = document
.querySelector('meta[name="description"], meta[property="og:description"]')
?.attributes["content"]
?.trim();
final root =
document.querySelector("article") ??
document.querySelector("main") ??
document.body ??
document.documentElement;
if (root == null) {
throw StateError("No readable content found at $url");
}
final buffer = StringBuffer();
if (title != null && title.isNotEmpty) {
buffer.writeln("# $title");
buffer.writeln();
}
if (description != null && description.isNotEmpty) {
buffer.writeln(description);
buffer.writeln();
}
for (final node in root.nodes) {
_writeNode(node, buffer, listDepth: 0, inPre: false);
}
var result = buffer.toString();
result = result.replaceAll(RegExp(r"\n{3,}"), "\n\n").trim();
result = _decodeHtmlEntities(result);
if (result.isEmpty) {
throw StateError("No readable content found at $url");
}
return _truncateContent(result);
}
void _writeNode(
dom.Node node,
StringBuffer buffer, {
required int listDepth,
required bool inPre,
}) {
if (node is dom.Text) {
final text = inPre
? node.text
: node.text.replaceAll(RegExp(r"\s+"), " ");
if (text.trim().isNotEmpty) {
buffer.write(text);
}
return;
}
if (node is! dom.Element) {
return;
}
final tag = node.localName?.toLowerCase() ?? "";
switch (tag) {
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
final level = int.tryParse(tag.substring(1)) ?? 1;
buffer
..writeln()
..write("${"#" * level} ${node.text.trim()}")
..writeln()
..writeln();
return;
case "p":
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
buffer.writeln();
buffer.writeln();
return;
case "br":
buffer.writeln();
return;
case "pre":
final code = node.text.trimRight();
if (code.isNotEmpty) {
buffer
..writeln()
..writeln("```")
..writeln(code)
..writeln("```")
..writeln();
}
return;
case "code":
final code = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
if (code.isNotEmpty) {
buffer.write("`$code`");
}
return;
case "ul":
case "ol":
buffer.writeln();
var index = 1;
for (final child in node.children.where((child) => child.localName == "li")) {
final prefix = tag == "ol" ? "${index++}." : "-";
buffer.write("${" " * listDepth}$prefix ");
_writeChildren(child, buffer, listDepth: listDepth + 1, inPre: false);
buffer.writeln();
}
buffer.writeln();
return;
case "li":
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
return;
case "a":
final label = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
final href = node.attributes["href"]?.trim();
if (label.isNotEmpty && href != null && href.isNotEmpty) {
buffer.write("[$label]($href)");
} else {
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
}
return;
case "blockquote":
final quote = node.text.trim();
if (quote.isNotEmpty) {
buffer
..writeln()
..writeln("> ${quote.replaceAll("\n", "\n> ")}")
..writeln();
}
return;
case "table":
final tableText = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
if (tableText.isNotEmpty) {
buffer
..writeln()
..writeln(tableText)
..writeln();
}
return;
case "hr":
buffer
..writeln()
..writeln("---")
..writeln();
return;
default:
final blockTags = <String>{
"article",
"section",
"main",
"div",
"header",
"footer",
"nav",
"aside",
};
final wasBlock = blockTags.contains(tag);
if (wasBlock) {
buffer.writeln();
}
_writeChildren(node, buffer, listDepth: listDepth, inPre: inPre);
if (wasBlock) {
buffer.writeln();
}
}
}
void _writeChildren(
dom.Element element,
StringBuffer buffer, {
required int listDepth,
required bool inPre,
}) {
for (final child in element.nodes) {
_writeNode(child, buffer, listDepth: listDepth, inPre: inPre);
}
}
Future<String> _summarizeFetchedContent({
required String apiKey,
required String model,
required _FetchedContent fetched,
required String prompt,
required bool isPreapproved,
}) async {
if (isPreapproved &&
fetched.contentType.contains("markdown") &&
fetched.content.length <= _maxPromptContentChars) {
return fetched.content;
}
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
try {
final response = await client.createMessage(
model: model,
maxTokens: 2048,
messages: <Map<String, dynamic>>[
<String, dynamic>{
"role": "system",
"content": isPreapproved
? "Provide a concise response based on the fetched content. Include relevant details and code examples when present."
: "Provide a concise response based only on the fetched content. Use short quotes only when necessary.",
},
<String, dynamic>{
"role": "user",
"content":
"URL: ${fetched.finalUrl}\n"
"Content-Type: ${fetched.contentType}\n\n"
"Web page content:\n---\n${fetched.content}\n---\n\n"
"$prompt",
},
],
);
final parts = <String>[];
for (final block in response.content) {
if (block is Map<String, dynamic> && block["type"] == "text") {
final text = block["text"];
if (text is String && text.isNotEmpty) {
parts.add(text);
}
}
}
final result = parts.join("\n").trim();
return result.isEmpty ? "No response from model." : result;
} finally {
client.close();
}
}
String _formatOutput({
required _FetchedContent fetched,
required int durationMs,
required String result,
}) {
final lines = <String>[
"URL: ${fetched.finalUrl}",
"Status: ${fetched.statusCode} ${fetched.reasonPhrase}",
"Bytes: ${fetched.bytes}",
"Duration: ${_formatDuration(durationMs)}",
];
if (fetched.persistedBinaryPath != null && fetched.persistedBinarySize != null) {
lines.add(
"Binary content saved: ${fetched.persistedBinaryPath} (${fetched.persistedBinarySize} bytes)",
);
}
lines
..add("")
..add(result.trim());
return lines.join("\n");
}
void _pruneCache() {
final now = DateTime.now();
_cacheOrder.removeWhere((key) {
final entry = _cache[key];
final expired = entry == null || now.difference(entry.fetchedAt) >= _cacheTtl;
if (expired) {
_cache.remove(key);
}
return expired;
});
}
void _storeCacheEntry(_CacheKey key, _FetchedContent content) {
_cache[key] = _CacheEntry(content: content, fetchedAt: DateTime.now());
_touchCacheEntry(key);
while (_cacheOrder.length > _maxCacheEntries) {
final removed = _cacheOrder.removeAt(0);
_cache.remove(removed);
}
}
void _touchCacheEntry(_CacheKey key) {
_cacheOrder.remove(key);
_cacheOrder.add(key);
}
bool _isPreapprovedUrl(String url) {
final uri = Uri.parse(url);
final host = uri.host.toLowerCase();
if (_preapprovedHosts.contains(host)) {
return true;
}
final prefixes = _preapprovedPathPrefixes[host];
if (prefixes == null) {
return false;
}
final pathName = uri.path;
return prefixes.any(
(prefix) => pathName == prefix || pathName.startsWith("$prefix/"),
);
}
bool _isLocalOrPrivateHost(String host) {
final lower = host.toLowerCase();
if (lower == "localhost" || !lower.contains(".")) {
return true;
}
if (lower.endsWith(".local")) {
return true;
}
final ipv4 = RegExp(r"^(\d{1,3}\.){3}\d{1,3}$");
if (ipv4.hasMatch(lower)) {
final parts = lower.split(".").map(int.parse).toList();
if (parts.any((part) => part < 0 || part > 255)) {
return true;
}
return parts[0] == 10 ||
parts[0] == 127 ||
(parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] == 192 && parts[1] == 168) ||
(parts[0] == 169 && parts[1] == 254);
}
if (lower == "::1" || lower.startsWith("fc") || lower.startsWith("fd")) {
return true;
}
return false;
}
bool _isRedirect(int statusCode) {
return statusCode == 301 ||
statusCode == 302 ||
statusCode == 307 ||
statusCode == 308;
}
bool _looksBinary(String contentType, List<int> bytes) {
if (contentType.startsWith("text/") ||
contentType.contains("json") ||
contentType.contains("xml") ||
contentType.contains("javascript") ||
contentType.contains("xhtml")) {
return false;
}
for (final byte in bytes.take(256)) {
if (byte == 0) {
return true;
}
}
return true;
}
String _truncateContent(String content) {
if (content.length <= _maxPromptContentChars) {
return content;
}
return "${content.substring(0, _maxPromptContentChars)}\n\n[Content truncated due to length...]";
}
String _decodeHtmlEntities(String text) {
return text
.replaceAll("&nbsp;", " ")
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", "\"")
.replaceAll("&#39;", "'")
.replaceAll("&#x27;", "'")
.replaceAll("&apos;", "'");
}
String _extensionForMimeType(String contentType) {
if (contentType.contains("pdf")) return ".pdf";
if (contentType.contains("zip")) return ".zip";
if (contentType.contains("png")) return ".png";
if (contentType.contains("jpeg")) return ".jpg";
if (contentType.contains("gif")) return ".gif";
if (contentType.contains("webp")) return ".webp";
if (contentType.contains("json")) return ".json";
return ".bin";
}
List<String> _readStringList(Object? value) {
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
String _randomSuffix() {
final radix = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
return radix.substring(radix.length - 6);
}
String _stripWww(String host) => host.replaceFirst(RegExp(r"^www\."), "");
String _formatDuration(int durationMs) {
if (durationMs < 1000) {
return "${durationMs}ms";
}
return "${(durationMs / 1000).toStringAsFixed(1)}s";
}
}
class _FetchedContent {
const _FetchedContent({
required this.finalUrl,
required this.statusCode,
required this.reasonPhrase,
required this.bytes,
required this.contentType,
required this.content,
this.persistedBinaryPath,
this.persistedBinarySize,
this.isRedirectNotice = false,
});
final String finalUrl;
final int statusCode;
final String reasonPhrase;
final int bytes;
final String contentType;
final String content;
final String? persistedBinaryPath;
final int? persistedBinarySize;
final bool isRedirectNotice;
}
class _PersistedBinary {
const _PersistedBinary({required this.path, required this.size});
final String path;
final int size;
}
class _CacheEntry {
const _CacheEntry({required this.content, required this.fetchedAt});
final _FetchedContent content;
final DateTime fetchedAt;
}
class _CacheKey {
const _CacheKey(this.url);
final String url;
@override
bool operator ==(Object other) =>
identical(this, other) || other is _CacheKey && other.url == url;
@override
int get hashCode => url.hashCode;
}
+336
View File
@@ -0,0 +1,336 @@
import "dart:convert";
import "dart:io";
import "../api/request_builder.dart";
import "base_tool.dart";
class WebSearchTool extends BaseTool {
@override
final String name = "WebSearch";
@override
final String description =
"Search the web for current information. Supports optional domain allow/block filters and returns a cited summary.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final query = requireString(input, "query").trim();
final allowedDomains = _readStringList(input["allowed_domains"]);
final blockedDomains = _readStringList(input["blocked_domains"]);
final apiKey = optionalString(input, "_api_key") ?? "";
final model = optionalString(input, "_model") ?? "openrouter/auto";
if (query.length < 2) {
throw ArgumentError("query must be at least 2 characters");
}
if (allowedDomains.isNotEmpty && blockedDomains.isNotEmpty) {
throw ArgumentError(
"Cannot specify both allowed_domains and blocked_domains in the same request",
);
}
if (apiKey.isEmpty) {
throw StateError("Web search requires an OpenRouter API key");
}
final startTime = DateTime.now();
final response = await _performSearch(
apiKey: apiKey,
model: model,
query: query,
allowedDomains: allowedDomains,
blockedDomains: blockedDomains,
);
final durationMs = DateTime.now().difference(startTime).inMilliseconds;
return _formatResult(
query: query,
response: response,
durationMs: durationMs,
);
}
Future<Map<String, dynamic>> _performSearch({
required String apiKey,
required String model,
required String query,
required List<String> allowedDomains,
required List<String> blockedDomains,
}) async {
final httpClient = HttpClient()..connectionTimeout = const Duration(seconds: 60);
try {
final request = await httpClient.openUrl(
"POST",
Uri.parse("https://openrouter.ai/api/v1/chat/completions"),
);
final headers = HeaderBuilder();
headers.addAuthHeader(apiKey);
headers.addOpenRouterHeaders();
for (final entry in headers.build().entries) {
request.headers.set(entry.key, entry.value);
}
request.headers.contentType = ContentType.json;
final searchTool = <String, dynamic>{
"type": "openrouter:web_search",
"parameters": <String, dynamic>{
"max_results": 5,
"max_total_results": 20,
"search_context_size": "medium",
},
};
final parameters = searchTool["parameters"] as Map<String, dynamic>;
if (allowedDomains.isNotEmpty) {
parameters["allowed_domains"] = allowedDomains;
}
if (blockedDomains.isNotEmpty) {
parameters["excluded_domains"] = blockedDomains;
}
final requestBody = <String, dynamic>{
"model": model,
"max_tokens": 2048,
"messages": <Map<String, dynamic>>[
<String, dynamic>{
"role": "system",
"content": _buildSearchPrompt(),
},
<String, dynamic>{
"role": "user",
"content": "Perform a web search for the query: $query",
},
],
"tools": <Map<String, dynamic>>[searchTool],
};
request.write(jsonEncode(requestBody));
final response = await request.close();
final responseBody = await response.transform(utf8.decoder).join();
if (response.statusCode >= 400) {
throw StateError(
"OpenRouter web search failed with HTTP ${response.statusCode}: $responseBody",
);
}
final decoded = jsonDecode(responseBody);
if (decoded is! Map<String, dynamic>) {
throw StateError("Unexpected web search response format");
}
return decoded;
} finally {
httpClient.close();
}
}
String _buildSearchPrompt() {
final now = DateTime.now();
final monthYear = "${_monthName(now.month)} ${now.year}";
return [
"You are an assistant for performing a web search tool use.",
"Use the provided web search results to answer the query.",
"After answering, you MUST include a 'Sources:' section with markdown links.",
"Use the current year when searching for recent information.",
"The current month is $monthYear.",
].join(" ");
}
String _formatResult({
required String query,
required Map<String, dynamic> response,
required int durationMs,
}) {
final choices = response["choices"];
Map<String, dynamic>? message;
if (choices is List && choices.isNotEmpty) {
final firstChoice = choices.first;
if (firstChoice is Map<String, dynamic>) {
final rawMessage = firstChoice["message"];
if (rawMessage is Map<String, dynamic>) {
message = rawMessage;
}
}
}
final content = _extractMessageContent(message);
final annotations = _extractAnnotations(message);
final sources = _extractSources(annotations);
final searchesPerformed = _extractSearchCount(response);
final buffer = StringBuffer()
..writeln("Query: $query")
..writeln("Duration: ${_formatDuration(durationMs)}")
..writeln("Searches performed: $searchesPerformed")
..writeln()
..writeln(content.isEmpty ? "No summary returned." : content.trim());
if (!_containsSourcesSection(content) && sources.isNotEmpty) {
buffer
..writeln()
..writeln("Sources:");
for (final source in sources) {
buffer.writeln("- [${source.title}](${source.url})");
}
}
return buffer.toString().trimRight();
}
String _extractMessageContent(Map<String, dynamic>? message) {
if (message == null) {
return "";
}
final content = message["content"];
if (content is String) {
return content;
}
if (content is List) {
final parts = <String>[];
for (final item in content) {
if (item is Map<String, dynamic>) {
final type = item["type"];
if (type == "text" || type == "output_text") {
final text = item["text"];
if (text is String && text.isNotEmpty) {
parts.add(text);
}
}
}
}
return parts.join("\n");
}
return "";
}
List<Map<String, dynamic>> _extractAnnotations(Map<String, dynamic>? message) {
if (message == null) {
return const <Map<String, dynamic>>[];
}
final annotations = <Map<String, dynamic>>[];
final topLevel = message["annotations"];
if (topLevel is List) {
for (final item in topLevel) {
if (item is Map<String, dynamic>) {
annotations.add(item);
}
}
}
final content = message["content"];
if (content is List) {
for (final item in content) {
if (item is! Map<String, dynamic>) {
continue;
}
final nested = item["annotations"];
if (nested is! List) {
continue;
}
for (final annotation in nested) {
if (annotation is Map<String, dynamic>) {
annotations.add(annotation);
}
}
}
}
return annotations;
}
List<_Source> _extractSources(List<Map<String, dynamic>> annotations) {
final seenUrls = <String>{};
final sources = <_Source>[];
for (final annotation in annotations) {
if (annotation["type"] != "url_citation") {
continue;
}
final citation = annotation["url_citation"];
final citationMap = citation is Map<String, dynamic>
? citation
: annotation;
final url = citationMap["url"];
if (url is! String || url.isEmpty || !seenUrls.add(url)) {
continue;
}
final title = citationMap["title"];
sources.add(
_Source(
title: title is String && title.isNotEmpty ? title : _hostForUrl(url),
url: url,
),
);
}
return sources;
}
int _extractSearchCount(Map<String, dynamic> response) {
final usage = response["usage"];
if (usage is! Map<String, dynamic>) {
return 0;
}
final serverToolUse = usage["server_tool_use"];
if (serverToolUse is! Map<String, dynamic>) {
return 0;
}
return (serverToolUse["web_search_requests"] as num?)?.toInt() ?? 0;
}
List<String> _readStringList(Object? value) {
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
bool _containsSourcesSection(String content) {
return RegExp(r"(^|\n)Sources:\s*$", caseSensitive: false).hasMatch(content);
}
String _hostForUrl(String url) {
final uri = Uri.tryParse(url);
return uri?.host.isNotEmpty == true ? uri!.host : url;
}
String _formatDuration(int durationMs) {
if (durationMs < 1000) {
return "${durationMs}ms";
}
return "${(durationMs / 1000).toStringAsFixed(1)}s";
}
String _monthName(int month) {
const names = <String>[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return names[month - 1];
}
}
class _Source {
const _Source({required this.title, required this.url});
final String title;
final String url;
}
+41 -50
View File
@@ -1,5 +1,5 @@
// Model pricing and cost calculation
// Ported from modelCost.ts
// Model pricing and cost calculation - Vendor-neutral version
// Pricing can be loaded from config or remote service
/// Per-million-token costs for a model
class ModelCosts {
@@ -8,6 +8,7 @@ class ModelCosts {
final double promptCacheWriteTokens;
final double promptCacheReadTokens;
final double webSearchRequests;
final String provider; // Vendor identifier
const ModelCosts({
required this.inputTokens,
@@ -15,73 +16,63 @@ class ModelCosts {
required this.promptCacheWriteTokens,
required this.promptCacheReadTokens,
this.webSearchRequests = 0.01,
this.provider = 'unknown',
});
}
// standard Sonnet pricing: $3 input / $15 output per Mtok
const costTier3_15 = ModelCosts(
// Default cost structure (can be overridden by config)
const defaultModelCosts = ModelCosts(
inputTokens: 3,
outputTokens: 15,
promptCacheWriteTokens: 3.75,
promptCacheReadTokens: 0.3,
provider: 'default',
);
// Opus 4/4.1 pricing: $15/$75
const costTier15_75 = ModelCosts(
// Cost tiers for reference (not hardcoded to specific vendor)
const costTierLow = ModelCosts(
inputTokens: 1,
outputTokens: 4,
promptCacheWriteTokens: 1.25,
promptCacheReadTokens: 0.1,
provider: 'generic',
);
const costTierMedium = ModelCosts(
inputTokens: 3,
outputTokens: 15,
promptCacheWriteTokens: 3.75,
promptCacheReadTokens: 0.3,
provider: 'generic',
);
const costTierHigh = ModelCosts(
inputTokens: 15,
outputTokens: 75,
promptCacheWriteTokens: 18.75,
promptCacheReadTokens: 1.5,
provider: 'generic',
);
// Opus 4.5: $5/$25
const costTier5_25 = ModelCosts(
inputTokens: 5,
outputTokens: 25,
promptCacheWriteTokens: 6.25,
promptCacheReadTokens: 0.5,
);
// Pricing map - populated from config/remote service
final Map<String, ModelCosts> modelCostMap = {
// Can be loaded from: config file, environment, remote service
};
// fast mode Opus 4.6: $30/$150
const costTier30_150 = ModelCosts(
inputTokens: 30,
outputTokens: 150,
promptCacheWriteTokens: 37.5,
promptCacheReadTokens: 3,
);
// Cost examples (can be loaded from config)
// const costExample = ModelCosts(
// inputTokens: 1,
// outputTokens: 5,
// promptCacheWriteTokens: 1.25,
// promptCacheReadTokens: 0.1,
// provider: 'example',
// );
// Haiku 3.5: $0.80/$4
const costHaiku35 = ModelCosts(
inputTokens: 0.8,
outputTokens: 4,
promptCacheWriteTokens: 1,
promptCacheReadTokens: 0.08,
);
const _defaultUnknownModelCost = defaultModelCosts;
// Haiku 4.5: $1/$5
const costHaiku45 = ModelCosts(
inputTokens: 1,
outputTokens: 5,
promptCacheWriteTokens: 1.25,
promptCacheReadTokens: 0.1,
);
const _defaultUnknownModelCost = costTier5_25;
// Model name -> cost mapping
// Model name -> cost mapping (can be loaded from config)
final Map<String, ModelCosts> modelCosts = {
"claude-3-5-haiku": costHaiku35,
"claude-haiku-4-5": costHaiku45,
"claude-3-5-sonnet-v2": costTier3_15,
"claude-3-7-sonnet": costTier3_15,
"claude-sonnet-4": costTier3_15,
"claude-sonnet-4-5": costTier3_15,
"claude-sonnet-4-6": costTier3_15,
"claude-opus-4": costTier15_75,
"claude-opus-4-1": costTier15_75,
"claude-opus-4-5": costTier5_25,
"claude-opus-4-6": costTier5_25,
// Can be populated from config file
};
+10 -4
View File
@@ -1,8 +1,9 @@
import "package:clawd_code/ui/screens/new_home_screen.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "providers/settings_provider.dart";
import "routes/router.dart";
class ClawdApp extends StatelessWidget {
const ClawdApp();
@@ -11,10 +12,15 @@ class ClawdApp extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, _) {
return ShadcnApp(
return ShadcnApp.router(
title: "Clawd",
home: NewHomeScreen(),
theme: ThemeData(colorScheme: ColorSchemes.darkNeutral, radius: 0.5),
routerConfig: AppRouter.router,
scaling: const AdaptiveScaling(0.9),
theme: ThemeData(
colorScheme: ColorSchemes.darkGray.rose,
density: Density.spaciousDensity,
radius: 0.5,
),
);
},
);
+20 -33
View File
@@ -36,40 +36,27 @@ const List<SelectableAiModel> selectableAiModels = [
group: "Recommended",
id: "qwen/qwen3-coder-next",
label: "Qwen3 Coder Next",
),
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3-235b-a22b-2507",
label: "Qwen3 235B A22B-2507",
),
SelectableAiModel(
group: "Recommended",
id: "google/gemma-4-31b-it",
label: "Gemma 4 31B IT",
),
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3.6-plus",
label: "Qwen3.6 Plus",
),
SelectableAiModel(
group: "Recommended",
id: "anthropic/claude-sonnet-4.6",
label: "Claude Sonnet 4.6",
)
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-sonnet-4.6",
// label: "Claude Sonnet 4.6",
// ),
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-opus-4.6",
// label: "Claude Opus 4.6",
// ),
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-haiku-4.5",
// label: "Claude Haiku 4.5",
// ),
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-5.4", label: "GPT-5.4"),
// SelectableAiModel(
// group: "OpenAI",
// id: "openai/gpt-5.4-mini",
// label: "GPT-5.4 Mini",
// ),
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-4.1", label: "GPT-4.1"),
// SelectableAiModel(group: "Qwen", id: "qwen/qwen3.5-9b", label: "Qwen3.5-9B"),
// SelectableAiModel(
// group: "Qwen",
// id: "qwen/qwen3.5-35b-a3b",
// label: "Qwen3.5-35B-A3B",
// ),
// SelectableAiModel(
// group: "Qwen",
// id: "qwen/qwen3.5-flash-02-23",
// label: "Qwen3.5-Flash",
// ),
];
+22
View File
@@ -0,0 +1,22 @@
import 'dart:typed_data';
class Attachment {
final String name;
final String mimeType;
final Uint8List data;
final DateTime createdAt;
Attachment({
required this.name,
required this.mimeType,
required this.data,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
bool get isImage => mimeType.startsWith('image/');
bool get isPdf => mimeType == 'application/pdf';
bool get isText => mimeType.startsWith('text/');
int get sizeInKB => (data.length / 1024).ceil();
String get displayName => name;
}
+150 -612
View File
@@ -1,18 +1,16 @@
import "package:file_picker/file_picker.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../src/project_store.dart";
import "../../../src/session/session_types.dart";
import "../../constants.dart";
import "../../providers/chat_provider.dart";
import "../../providers/cost_provider.dart";
import "../../providers/home_coordinator.dart";
import "../../providers/projects_provider.dart";
import "../../providers/session_provider.dart";
import "../../providers/settings_provider.dart";
import "../../widgets/app_header.dart";
import "../../widgets/chat_view.dart";
import "../../widgets/settings_sheet.dart";
import "../../widgets/agents/agents_pane.dart";
import "../../widgets/chat/chat_box.dart";
import "../../widgets/chat/chat_view.dart";
import "../../widgets/common/footer_bar.dart";
import "../../widgets/sidebar/sidebar.dart";
class NewHomeScreen extends StatefulWidget {
const NewHomeScreen({super.key});
@@ -22,202 +20,34 @@ class NewHomeScreen extends StatefulWidget {
}
class _NewHomeScreenState extends State<NewHomeScreen> {
late final TextEditingController _messageController;
final ScrollController _chatScrollController = ScrollController();
@override
void initState() {
super.initState();
_messageController = TextEditingController();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<HomeCoordinator>().addListener(_onCoordinatorChanged);
});
}
@override
void dispose() {
_messageController.dispose();
context.read<HomeCoordinator>().removeListener(_onCoordinatorChanged);
_chatScrollController.dispose();
super.dispose();
}
Iterable<MapEntry<String, List<String>>> _filteredModels(String searchQuery) {
final normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return _modelGroups.entries;
}
return _modelGroups.entries
.map((entry) {
final matchingModels = entry.value
.where(
(modelId) =>
modelId.toLowerCase().contains(normalizedQuery) ||
_modelLabel(
modelId,
).toLowerCase().contains(normalizedQuery),
)
.toList();
return MapEntry(entry.key, matchingModels);
})
.where((entry) => entry.value.isNotEmpty);
}
Map<String, List<String>> get _modelGroups {
final groups = <String, List<String>>{};
for (final model in selectableAiModels) {
groups.putIfAbsent(model.group, () => <String>[]).add(model.id);
}
return groups;
}
String _modelLabel(String modelId) {
for (final model in selectableAiModels) {
if (model.id == modelId) {
return model.label;
}
}
return modelId;
}
Future<void> _pickProjectDirectory() async {
try {
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: "Select project directory",
);
if (selectedDirectory == null || !mounted) {
return;
}
final projectsProvider = context.read<ProjectsProvider>();
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
final project = await projectsProvider.addProject(selectedDirectory);
if (project == null && mounted) {
await _showProjectPickerError(
"The selected folder could not be added as a project.",
);
return;
}
projectsProvider.selectProject(project!.id);
sessionProvider.clearCurrentSession(
workingDirectory: project.workingDirectory,
);
chatProvider.clearConversation();
} catch (error, stackTrace) {
print("Project directory picker failed: $error");
print(stackTrace);
if (!mounted) {
return;
}
await _showProjectPickerError(error.toString());
void _onCoordinatorChanged() {
final coordinator = context.read<HomeCoordinator>();
final err = coordinator.error;
if (err != null) {
coordinator.clearError();
_showError(err);
}
}
Future<void> _createNewChat() async {
final projectsProvider = context.read<ProjectsProvider>();
final selectedProject = projectsProvider.selectedProject;
if (selectedProject == null) {
await _showProjectPickerError(
"Choose a project first so the new chat has a working directory.",
);
return;
}
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
await sessionProvider.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
);
chatProvider.setConversation(sessionProvider.getConversationHistory());
}
Future<void> _selectProject(ProjectRecord project) async {
final projectsProvider = context.read<ProjectsProvider>();
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
projectsProvider.selectProject(project.id);
if (sessionProvider.currentSession?.workingDirectory ==
project.workingDirectory) {
return;
}
sessionProvider.clearCurrentSession(
workingDirectory: project.workingDirectory,
);
chatProvider.clearConversation();
}
Future<void> _openSession(SessionSummary session) async {
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
final projectsProvider = context.read<ProjectsProvider>();
await sessionProvider.loadSession(session.id);
chatProvider.setConversation(sessionProvider.getConversationHistory());
projectsProvider.selectProjectByWorkingDirectory(
sessionProvider.activeWorkingDirectory,
);
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty) {
return;
}
final sessionProvider = context.read<SessionProvider>();
final projectsProvider = context.read<ProjectsProvider>();
final chatProvider = context.read<ChatProvider>();
final selectedProject = projectsProvider.selectedProject;
if (sessionProvider.currentSession == null) {
if (selectedProject == null) {
await _showProjectPickerError("Pick a project before starting a chat.");
return;
}
await sessionProvider.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
);
chatProvider.setConversation(sessionProvider.getConversationHistory());
}
_messageController.clear();
try {
await chatProvider.sendMessage(text);
if (!mounted) {
return;
}
} catch (error, stackTrace) {
print("Failed to send message from home screen: $error");
print(stackTrace);
if (!mounted) {
return;
}
await _showProjectPickerError(error.toString());
} finally {
if (!mounted) {
return;
}
await context.read<SessionProvider>().refreshSessions();
}
}
void _stopMessage() {
context.read<ChatProvider>().stopGenerating();
}
void _openSettings() {
showDialog<void>(
context: context,
builder: (_) => const AlertDialog(content: SettingsSheet()),
);
}
Future<void> _showProjectPickerError(String message) {
Future<void> _showError(String message) {
return showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
@@ -235,439 +65,78 @@ class _NewHomeScreenState extends State<NewHomeScreen> {
@override
Widget build(BuildContext context) {
final projectsProvider = context.watch<ProjectsProvider>();
final sessionProvider = context.watch<SessionProvider>();
final chatProvider = context.watch<ChatProvider>();
final settingsProvider = context.watch<SettingsProvider>();
final costProvider = context.watch<CostProvider>();
// Group sessions by working directory
final sessionsByProject = <String, List<SessionSummary>>{};
for (final session in sessionProvider.sessions) {
final workingDirectory = session.workingDirectory ?? '';
if (!sessionsByProject.containsKey(workingDirectory)) {
sessionsByProject[workingDirectory] = <SessionSummary>[];
}
sessionsByProject[workingDirectory]!.add(session);
}
final selectedProject = projectsProvider.selectedProject;
final selectedWorkingDirectory = selectedProject?.workingDirectory;
final currentModel = settingsProvider.normalizeModelId(
settingsProvider.settings.model,
);
return Scaffold(
child: Row(
child: Column(
children: [
SizedBox(
width: 320,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expanded(
child: Row(
children: [
const Gap(16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppHeader(),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
Sidebar(),
Gap(1),
VerticalDivider(),
Expanded(
child: Stack(
children: [
SizedBox(
width: double.infinity,
child: Button.ghost(
leading: const Icon(LucideIcons.folderPlus),
leadingGap: 12,
onPressed: _pickProjectDirectory,
child: Transform.translate(
offset: const Offset(0, 1),
child: const Align(
alignment: Alignment.centerLeft,
child: Text("New Project"),
),
),
),
),
const Gap(8),
SizedBox(
width: double.infinity,
child: Button.ghost(
leading: const Icon(LucideIcons.circlePlus),
leadingGap: 12,
onPressed:
selectedProject == null || chatProvider.isLoading
? null
: _createNewChat,
child: Transform.translate(
offset: const Offset(0, 1),
child: const Align(
alignment: Alignment.centerLeft,
child: Text("New Chat"),
),
),
),
_ChatArea(scrollController: _chatScrollController),
Positioned(
top: 0,
bottom: 0,
right: 0,
width: 12,
child: FullHeightScrollbar(controller: _chatScrollController),
),
],
),
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text("All Threads").textSmall.muted,
),
Expanded(
child: _ThreadsSection(
projectsProvider: projectsProvider,
sessionProvider: sessionProvider,
sessionsByProject: sessionsByProject,
onOpenSession: _openSession,
onSelectProject: _selectProject,
),
),
AgentsPane(),
],
),
),
const VerticalDivider(),
Expanded(
child: Column(
children: [
if (selectedProject != null && sessionProvider.currentSession != null)...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12
),
child: Row(
children: [
FooterBar(),
Icon(
LucideIcons.messageCircle
).iconSmall,
Gap(8),
Transform.translate(
offset: Offset(0, -1),
child: Row(
children: [
Text(
selectedProject.name
).textSmall,
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(
LucideIcons.slash
).iconX2Small,
),
Text(
sessionProvider.currentSession!.name
).textSmall
],
),
),
],
),
),
Divider(),
],
const Gap(18),
Expanded(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 600
),
child: Column(
children: [
Expanded(
child: ClipRect(
child: chatProvider.messages.isEmpty
? _EmptyChatState(
projectName: selectedProject?.name,
hasProject: selectedProject != null,
)
: const ChatView(),
),
),
const Gap(16),
TextField(
controller: _messageController,
minLines: 3,
maxLines: 6,
enabled: !chatProvider.isLoading,
placeholder: Text(
selectedProject == null
? "Choose a project to start chatting"
: "Ask a question or type a message",
),
onSubmitted: chatProvider.isLoading
? null
: (_) => _sendMessage(),
features: [
InputFeature.below(
Row(
children: [
IconButton.ghost(
onPressed: _pickProjectDirectory,
icon: const Icon(LucideIcons.folderSearch),
),
const Spacer(),
Select<String>(
itemBuilder: (context, item) {
return Text(_modelLabel(item));
},
popup: SelectPopup.builder(
searchPlaceholder: const Text("Search models"),
builder: (context, searchQuery) {
final filteredModels = searchQuery == null
? _modelGroups.entries
: _filteredModels(searchQuery);
return SelectItemList(
children: [
for (final entry in filteredModels)
SelectGroup(
headers: [
SelectLabel(child: Text(entry.key)),
],
children: [
for (final modelId in entry.value)
SelectItemButton(
value: modelId,
child: Text(
_modelLabel(modelId),
),
),
],
),
],
);
},
),
onChanged: (value) {
if (value != null) {
settingsProvider.updateModel(value);
}
},
constraints: const BoxConstraints(minWidth: 220),
value: currentModel,
placeholder: const Text("Select a model"),
),
const Gap(10),
Button.primary(
onPressed: chatProvider.isLoading
? _stopMessage
: _sendMessage,
child: chatProvider.isLoading
? Text(
chatProvider.isStopping
? "Stopping..."
: "Stop",
)
: const Text("Send"),
),
],
),
),
],
),
],
),
),
)
],
),
),
],
),
);
}
}
class _SidebarHint extends StatelessWidget {
const _SidebarHint({required this.text});
final String text;
class _ChatArea extends StatelessWidget {
final ScrollController scrollController;
const _ChatArea({required this.scrollController});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(text).textSmall.muted,
);
}
}
final chatProvider = context.watch<ChatProvider>();
class _ThreadsSection extends StatelessWidget {
const _ThreadsSection({
required this.projectsProvider,
required this.sessionProvider,
required this.sessionsByProject,
required this.onOpenSession,
required this.onSelectProject,
});
final ProjectsProvider projectsProvider;
final SessionProvider sessionProvider;
final Map<String, List<SessionSummary>> sessionsByProject;
final ValueChanged<SessionSummary> onOpenSession;
final ValueChanged<ProjectRecord> onSelectProject;
@override
Widget build(BuildContext context) {
// Sort sessions by update time (newest first) within each project
final sortedSessionsByProject = <String, List<SessionSummary>>{};
sessionsByProject.forEach((workingDirectory, sessions) {
final sortedSessions = List<SessionSummary>.from(sessions)
..sort((a, b) => b.updated.compareTo(a.updated));
sortedSessionsByProject[workingDirectory] = sortedSessions;
});
return ListView(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
children: [
if (projectsProvider.projects.isEmpty)
const _SidebarHint(text: "No projects yet")
else
for (final project in projectsProvider.projects)
...[
// Project header
SizedBox(
width: double.infinity,
child: Button.ghost(
onPressed: () => onSelectProject(project),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
project.name,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
),
),
// Project sessions
if (sortedSessionsByProject[project.workingDirectory]?.isEmpty ?? true)
const Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
child: _SidebarHint(text: "No threads yet"),
)
else
for (final session in sortedSessionsByProject[project.workingDirectory]!)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
),
const Divider(height: 16),
],
// Handle sessions that don't belong to any current project
if (sortedSessionsByProject.keys.any((key) => !projectsProvider.projects.any((project) => project.workingDirectory == key)))
...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(
"Sessions Without Projects",
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
for (final entry in sortedSessionsByProject.entries)
if (!projectsProvider.projects.any((project) => project.workingDirectory == entry.key) && entry.key.isNotEmpty)
for (final session in entry.value)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
),
],
],
);
}
}
class _SidebarSessionTile extends StatelessWidget {
const _SidebarSessionTile({
required this.session,
required this.isSelected,
required this.onTap,
});
final SessionSummary session;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Button(
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
child: Text(
session.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).textSmall,
trailing: Text(
_formatRelativeTime(session.updated),
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).muted.textSmall,
onPressed: () {
onTap();
},
),
);
}
}
class _EmptyChatState extends StatelessWidget {
const _EmptyChatState({required this.projectName, required this.hasProject});
final String? projectName;
final bool hasProject;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
return Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.messagesSquare, size: 28),
const Gap(16),
Text(
hasProject
? "Ready to chat about ${projectName ?? "this project"}"
: "Choose a project to begin",
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
Expanded(
child: chatProvider.messages.isEmpty
? _EmptyChatState()
: ChatView(scrollController: scrollController),
),
const Gap(8),
Text(
hasProject
? "This chat will use the selected folder as its working directory."
: "The desktop app uses the picked folder instead of the shell launch directory.",
textAlign: TextAlign.center,
).textSmall.muted,
ChatBox(),
],
),
),
@@ -675,23 +144,92 @@ class _EmptyChatState extends StatelessWidget {
}
}
String _formatRelativeTime(DateTime timestamp) {
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
if (difference.inMinutes < 1) {
return "just now";
}
if (difference.inHours < 1) {
return "${difference.inMinutes}m";
}
if (difference.inDays < 1) {
return "${difference.inHours}h";
}
if (difference.inDays < 7) {
return "${difference.inDays}d";
}
class _EmptyChatState extends StatelessWidget {
final month = timestamp.month.toString().padLeft(2, "0");
final day = timestamp.day.toString().padLeft(2, "0");
return "${timestamp.year}-$month-$day";
const _EmptyChatState();
@override
Widget build(BuildContext context) {
final projectsProvider = context.watch<ProjectsProvider>();
final projects = projectsProvider.projects;
final selected = projectsProvider.selectedProject;
final coordinator = context.read<HomeCoordinator>();
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.messagesSquare, size: 28),
const Gap(16),
Text(
"Ask the agency anything",
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
"Select a project and thread from the sidebar, or start a new chat.",
textAlign: TextAlign.center,
).textSmall.muted,
const Gap(24),
Select<ProjectRecord>(
itemBuilder: (context, item) => Text(item.name),
popup: SelectPopup.builder(
searchPlaceholder: const Text("Search projects"),
builder: (context, searchQuery) {
final filtered = searchQuery == null || searchQuery.isEmpty
? projects
: projects.where((p) =>
p.name.toLowerCase().contains(searchQuery.toLowerCase()) ||
p.workingDirectory.toLowerCase().contains(searchQuery.toLowerCase())
).toList();
return SelectItemList(
children: [
for (final project in filtered)
SelectItemButton(
value: project,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(project.name),
Text(project.workingDirectory).textSmall.muted,
],
),
),
],
);
},
),
onChanged: (project) {
if (project != null) coordinator.selectProject(project);
},
constraints: const BoxConstraints(minWidth: 240),
value: selected,
placeholder: const Text("Select a project"),
),
],
),
),
);
}
}
abstract class HomeScreenRoute {
static const path = '/';
static const name = 'home';
static GoRoute get route => GoRoute(
path: path,
name: name,
builder: (context, state) => const NewHomeScreen(),
);
}
@@ -0,0 +1,193 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../src/project_store.dart";
import "../../../../src/session/session_types.dart";
import "../../../providers/projects_provider.dart";
import "../../../providers/session_provider.dart";
class ThreadsSection extends StatelessWidget {
const ThreadsSection({
required this.projectsProvider,
required this.sessionProvider,
required this.sessionsByProject,
required this.onOpenSession,
required this.onSelectProject,
required this.onDeleteSession,
});
final ProjectsProvider projectsProvider;
final SessionProvider sessionProvider;
final Map<String, List<SessionSummary>> sessionsByProject;
final ValueChanged<SessionSummary> onOpenSession;
final ValueChanged<ProjectRecord> onSelectProject;
final ValueChanged<SessionSummary> onDeleteSession;
@override
Widget build(BuildContext context) {
// Sort sessions by update time (newest first) within each project
final sortedSessionsByProject = <String, List<SessionSummary>>{};
sessionsByProject.forEach((workingDirectory, sessions) {
final sortedSessions = List<SessionSummary>.from(sessions)
..sort((a, b) => b.updated.compareTo(a.updated));
sortedSessionsByProject[workingDirectory] = sortedSessions;
});
return ListView(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
children: [
if (projectsProvider.projects.isEmpty)
const _SidebarHint(text: "No projects yet")
else
for (final project in projectsProvider.projects) ...[
// Project header
SizedBox(
width: double.infinity,
child: Button.ghost(
onPressed: () => onSelectProject(project),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
project.name,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
),
),
// Project sessions
if (sortedSessionsByProject[project.workingDirectory]?.isEmpty ??
true)
const Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
child: _SidebarHint(text: "No threads yet"),
)
else
for (final session
in sortedSessionsByProject[project.workingDirectory]!)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
onDelete: () => onDeleteSession(session),
),
const Divider(height: 16),
],
// Handle sessions that don't belong to any current project
if (sortedSessionsByProject.keys.any(
(key) => !projectsProvider.projects.any(
(project) => project.workingDirectory == key,
),
)) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(
"Sessions Without Projects",
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
for (final entry in sortedSessionsByProject.entries)
if (!projectsProvider.projects.any(
(project) => project.workingDirectory == entry.key,
) &&
entry.key.isNotEmpty)
for (final session in entry.value)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
onDelete: () => onDeleteSession(session),
),
],
],
);
}
}
class _SidebarHint extends StatelessWidget {
const _SidebarHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(text).textSmall.muted,
);
}
}
class _SidebarSessionTile extends StatelessWidget {
const _SidebarSessionTile({
required this.session,
required this.isSelected,
required this.onTap,
required this.onDelete,
});
final SessionSummary session;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return ContextMenu(
items: [
MenuButton(
onPressed: (context) {
onDelete();
},
child: const Text("Delete"),
),
],
child: SizedBox(
width: double.infinity,
child: Button(
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
child: Text(
session.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).textSmall,
trailing: Text(
_formatRelativeTime(session.updated),
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).muted.textSmall,
onPressed: () {
onTap();
},
),
),
);
}
}
String _formatRelativeTime(DateTime timestamp) {
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
if (difference.inMinutes < 1) {
return "just now";
}
if (difference.inHours < 1) {
return "${difference.inMinutes}m";
}
if (difference.inDays < 1) {
return "${difference.inHours}h";
}
if (difference.inDays < 7) {
return "${difference.inDays}d";
}
final month = timestamp.month.toString().padLeft(2, "0");
final day = timestamp.day.toString().padLeft(2, "0");
return "${timestamp.year}-$month-$day";
}
+107
View File
@@ -0,0 +1,107 @@
import "package:go_router/go_router.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class ProjectDetailPage extends StatelessWidget {
const ProjectDetailPage({
super.key,
required this.projectId,
this.tab = 'overview',
});
final String projectId;
final String tab;
@override
Widget build(BuildContext context) {
return Scaffold(
headers: [
AppBar(
title: Text("Project: $projectId"),
leading: [
IconButton.ghost(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.go('/'),
),
],
),
],
child: Column(
children: [
// Tab navigation
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_buildTabButton(context, 'overview', 'Overview'),
const Gap(8),
_buildTabButton(context, 'files', 'Files'),
const Gap(8),
_buildTabButton(context, 'settings', 'Settings'),
],
),
),
const Divider(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: _buildTabContent(),
),
),
],
),
);
}
Widget _buildTabButton(BuildContext context, String tabName, String label) {
final isActive = tab == tabName;
return Button(
style: isActive ? ButtonStyle.secondary() : ButtonStyle.ghost(),
onPressed: () => context.go(
ProjectDetailRoute.pathWithParams(
projectId: projectId,
tab: tabName,
),
),
child: Text(label),
);
}
Widget _buildTabContent() {
switch (tab) {
case 'files':
return const Center(child: Text("Files tab content"));
case 'settings':
return const Center(child: Text("Settings tab content"));
case 'overview':
default:
return const Center(child: Text("Project overview content"));
}
}
}
/// GoRouter routes for the project detail page
abstract class ProjectDetailRoute {
static const path = '/projects/:projectId';
static const name = 'project_detail';
static String pathWithParams({
required String projectId,
String tab = 'overview',
}) {
return '/projects/$projectId?tab=$tab';
}
static GoRoute get route => GoRoute(
path: path,
name: name,
builder: (context, state) {
final projectId = state.pathParameters['projectId']!;
final tab = state.uri.queryParameters['tab'] ?? 'overview';
return ProjectDetailPage(
projectId: projectId,
tab: tab,
);
},
);
}
+68
View File
@@ -0,0 +1,68 @@
import "package:go_router/go_router.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "widgets/setting_card.dart";
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
headers: [
AppBar(
title: const Text("Settings"),
leading: [
IconButton.ghost(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.go('/'),
),
],
),
],
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SettingCard(
title: "Appearance",
description: "Customize theme, colors, and layout",
icon: LucideIcons.palette,
onTap: () {
// Could navigate to appearance settings
},
),
const Gap(12),
SettingCard(
title: "Models",
description: "Configure AI model preferences",
icon: LucideIcons.brain,
onTap: () {
// Could navigate to model settings
},
),
const Gap(12),
SettingCard(
title: "Advanced",
description: "Developer options and advanced settings",
icon: LucideIcons.settings2,
onTap: () {
// Could navigate to advanced settings
},
),
],
),
);
}
}
/// GoRouter routes for the settings page
abstract class SettingsRoute {
static const path = '/settings';
static const name = 'settings';
static GoRoute get route => GoRoute(
path: path,
name: name,
builder: (context, state) => const SettingsPage(),
);
}
@@ -0,0 +1,48 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class SettingCard extends StatelessWidget {
const SettingCard({
super.key,
required this.title,
required this.description,
required this.icon,
this.onTap,
});
final String title;
final String description;
final IconData icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon).iconLarge,
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title).textLarge,
const Gap(4),
Text(description).textSmall.muted,
],
),
),
if (onTap != null) ...[
const Gap(8),
IconButton.ghost(
onPressed: onTap,
icon: const Icon(LucideIcons.chevronRight),
),
],
],
),
),
);
}
}
+151 -18
View File
@@ -3,48 +3,130 @@ import "dart:convert";
import "../../src/chat/tool_loop_service.dart";
import "../../src/api/openrouter_client.dart";
import "../../src/hooks/hook_loader.dart";
import "../../src/hooks/hook_runner.dart";
import "../../src/hooks/hook_types.dart";
import "../../src/permissions/permission_types.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
import "settings_provider.dart";
enum QueuePriority {
now(0),
next(1),
later(2);
final int order;
const QueuePriority(this.order);
}
class QueuedMessage {
final String text;
final QueuePriority priority;
const QueuedMessage({required this.text, required this.priority});
}
class ChatProvider extends ChangeNotifier {
ChatProvider(this._settingsProvider);
ChatProvider(this._settingsProvider) {
_initHooks();
}
final SettingsProvider _settingsProvider;
final ToolLoopService _toolLoopService = ToolLoopService();
ToolLoopService _toolLoopService = ToolLoopService();
HookRunner? _hookRunner;
ConversationHistory? _conversationHistory;
OpenRouterClient? _client;
bool _stopRequested = false;
PendingPermission? _pendingPermission;
PendingPermission? get pendingPermission => _pendingPermission;
Future<void> _initHooks() async {
try {
final hooks = await HookLoader.loadHooks();
_hookRunner = HookRunner(hooks: hooks);
_toolLoopService = ToolLoopService(hookRunner: _hookRunner);
} catch (e) {
// hooks are optional, carry on without them
print("Hook init failed: $e");
}
}
List<Message> _messages = <Message>[];
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
bool isLoading = false;
final List<QueuedMessage> _messageQueue = [];
List<Message> get messages => _messages;
int get messageCount => _messages.length;
List<Message> get messages => _conversationHistory?.getMessages() ?? const [];
int get messageCount => messages.length;
String? get workingDirectory => _conversationHistory?.session?.workingDirectory;
/// Context window size from the last API response — derived from persisted
/// message data, same as Claude Code (walks backwards to find the last
/// assistant message that has contextTokens set).
int get contextTokens {
final msgs = messages;
for (var i = msgs.length - 1; i >= 0; i--) {
final ct = msgs[i].contextTokens;
if (ct != null && ct > 0) return ct;
}
return 0;
}
bool get hasConversation => _conversationHistory != null;
bool get isStopping => _stopRequested;
int get queuedMessageCount => _messageQueue.length;
// only user-visible messages (priority != now)
List<String> get queuedMessages =>
List.unmodifiable(_messageQueue.map((m) => m.text));
void removeQueuedMessage(int index) {
if (index < 0 || index >= _messageQueue.length) return;
_messageQueue.removeAt(index);
notifyListeners();
}
QueuedMessage? _dequeue() {
if (_messageQueue.isEmpty) return null;
int bestIdx = 0;
for (int i = 1; i < _messageQueue.length; i++) {
if (_messageQueue[i].priority.order < _messageQueue[bestIdx].priority.order) {
bestIdx = i;
}
}
final cmd = _messageQueue[bestIdx];
_messageQueue.removeAt(bestIdx);
return cmd;
}
void setConversation(ConversationHistory history) {
_conversationHistory = history;
_messages = history.getMessages();
_apiMessages = _buildApiMessages(_messages);
_apiMessages = _buildApiMessages(history.getMessages());
notifyListeners();
}
void clearConversation() {
_conversationHistory = null;
_messages = <Message>[];
_apiMessages = <Map<String, dynamic>>[];
_messageQueue.clear();
isLoading = false;
notifyListeners();
}
Future<void> sendMessage(String text) async {
Future<void> sendMessage(String text, {QueuePriority priority = QueuePriority.next}) async {
if (text.isEmpty || _conversationHistory == null) return;
if (isLoading) {
_messageQueue.add(QueuedMessage(text: text, priority: priority));
notifyListeners();
return;
}
final apiKey = _settingsProvider.settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
throw Exception(
@@ -72,25 +154,35 @@ class ChatProvider extends ChangeNotifier {
}
}
// fire UserPromptSubmit hook
await _hookRunner?.runHooksForKind(
HookKind.userPromptSubmit,
input: {"message": text},
);
// add user message to conversation
_conversationHistory!.addMessage("user", text);
_messages = _conversationHistory!.getMessages();
_apiMessages.add(<String, dynamic>{"role": "user", "content": text});
isLoading = true;
notifyListeners();
final advisorModel = _settingsProvider.settings.advisorModel;
final toolLoopResult = await _toolLoopService.runTurn(
client: _client!,
model: model,
apiKey: apiKey,
getSettings: () => _settingsProvider.settings,
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
userText: text,
workingDirectory: workingDirectory,
advisorModel: advisorModel,
onToolCall: (toolName, input) {
_conversationHistory!.addMessage(
"tool",
_formatToolCall(toolName, input),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onToolResult: (toolName, result) {
@@ -98,7 +190,6 @@ class ChatProvider extends ChangeNotifier {
"tool",
_formatToolResult(toolName, result),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onAssistantTextDelta: (delta) {
@@ -107,26 +198,38 @@ class ChatProvider extends ChangeNotifier {
hasStreamingAssistantMessage = true;
}
_conversationHistory!.appendToLastMessage(delta);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onPermissionRequired: (toolName, input) async {
final pending = PendingPermission(toolName: toolName, input: input);
_pendingPermission = pending;
notifyListeners();
final decision = await pending.future;
_pendingPermission = null;
notifyListeners();
return decision;
},
);
_apiMessages = toolLoopResult.apiMessages;
final ct = toolLoopResult.response.contextTokens;
// add assistant message to visible conversation
if (!toolLoopResult.finalResponseWasStreamed) {
_conversationHistory!.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
contextTokens: ct,
);
} else {
// streamed message was built incrementally — patch contextTokens onto it
_conversationHistory!.setLastMessageContextTokens(ct);
}
_messages = _conversationHistory!.getMessages();
// track cost (set to 0 for now — OpenRouter pricing varies by model)
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
@@ -138,6 +241,8 @@ class ChatProvider extends ChangeNotifier {
outputTokens: outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
@@ -154,7 +259,7 @@ class ChatProvider extends ChangeNotifier {
if (error is RequestCancelledException) {
_conversationHistory!.addMessage("assistant", "Generation stopped.");
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
@@ -171,7 +276,7 @@ class ChatProvider extends ChangeNotifier {
);
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
@@ -183,6 +288,26 @@ class ChatProvider extends ChangeNotifier {
isLoading = false;
notifyListeners();
}
final next = _dequeue();
if (next != null) {
notifyListeners();
await sendMessage(next.text, priority: next.priority);
}
}
void resolvePermission(PermissionDecision decision) async {
final pending = _pendingPermission;
if (pending == null) return;
if (decision == PermissionDecision.allowAlways) {
// persist to settings so this tool is auto-allowed from now on
await _settingsProvider.addAlwaysAllowRule(pending.toolName);
}
pending.resolve(decision);
_pendingPermission = null;
notifyListeners();
}
void stopGenerating() {
@@ -190,10 +315,15 @@ class ChatProvider extends ChangeNotifier {
return;
}
_pendingPermission?.resolve(PermissionDecision.reject);
_pendingPermission = null;
_messageQueue.clear();
_stopRequested = true;
print("Stopping active turn");
_client?.cancelActiveRequest();
notifyListeners();
_hookRunner?.runHooksForKind(HookKind.stop);
}
@override
@@ -232,7 +362,10 @@ class ChatProvider extends ChangeNotifier {
String _formatToolCall(String toolName, Map<String, dynamic> input) {
const encoder = JsonEncoder.withIndent(" ");
return "$toolName call\n${encoder.convert(input)}";
final visibleInput = Map<String, dynamic>.fromEntries(
input.entries.where((entry) => !entry.key.startsWith("_")),
);
return "$toolName call\n${encoder.convert(visibleInput)}";
}
String _formatToolResult(String toolName, String result) {
+126
View File
@@ -0,0 +1,126 @@
import "package:file_picker/file_picker.dart";
import "package:flutter/foundation.dart";
import "../../src/project_store.dart";
import "../../src/session/session_types.dart";
import "chat_provider.dart";
import "projects_provider.dart";
import "session_provider.dart";
import "settings_provider.dart";
class HomeCoordinator extends ChangeNotifier {
HomeCoordinator(this._projects, this._session, this._chat, this._settings);
final ProjectsProvider _projects;
final SessionProvider _session;
final ChatProvider _chat;
final SettingsProvider _settings;
String? _error;
String? get error => _error;
void clearError() {
_error = null;
notifyListeners();
}
void _setError(String msg) {
_error = msg;
notifyListeners();
}
Future<void> pickProjectDirectory() async {
try {
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: "Select project directory",
);
if (selectedDirectory == null) return;
final project = await _projects.addProject(selectedDirectory);
if (project == null) {
_setError("The selected folder could not be added as a project.");
return;
}
_projects.selectProject(project.id);
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
_chat.clearConversation();
await _settings.setActiveProject(project.workingDirectory);
} catch (e, st) {
print("Project directory picker failed: $e");
print(st);
_setError(e.toString());
}
}
Future<void> createNewChat() async {
final selectedProject = _projects.selectedProject;
if (selectedProject == null) {
_setError("Choose a project first so the new chat has a working directory.");
return;
}
await _session.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
}
Future<void> selectProject(ProjectRecord project) async {
_projects.selectProject(project.id);
await _settings.setActiveProject(project.workingDirectory);
if (_session.currentSession?.workingDirectory == project.workingDirectory) return;
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
_settings.setThreadModel(null);
_chat.clearConversation();
}
Future<void> openSession(SessionSummary session) async {
await _session.loadSession(session);
_chat.setConversation(_session.getConversationHistory());
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
_settings.setThreadModel(_session.currentSession?.model);
}
Future<void> sendMessage(String text) async {
if (text.isEmpty) return;
if (_session.currentSession == null) {
final selectedProject = _projects.selectedProject;
if (selectedProject == null) {
_setError("Pick a project before starting a chat.");
return;
}
await _session.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
}
try {
await _chat.sendMessage(text);
} catch (e, st) {
print("Failed to send message: $e");
print(st);
_setError(e.toString());
} finally {
await _session.refreshSessions();
}
}
Future<void> deleteSession(SessionSummary session) async {
await _session.deleteSession(session);
}
}
+37 -10
View File
@@ -1,15 +1,17 @@
import "package:flutter/foundation.dart";
import "package:uuid/uuid.dart";
import "../../src/project_store.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
class SessionProvider extends ChangeNotifier {
SessionProvider() {
SessionProvider(this._projectStore) {
_loadSessions();
}
final ProjectStore _projectStore;
final SessionStore _sessionStore = SessionStore.instance;
final ConversationHistory _conversationHistory = ConversationHistory();
@@ -59,7 +61,12 @@ class SessionProvider extends ChangeNotifier {
Future<void> _loadSessions() async {
try {
_sessions = await _sessionStore.listSessions();
final workingDirs = _projectStore.projects
.map((p) => p.workingDirectory)
.where((d) => d.isNotEmpty)
.toList();
_sessions = await _sessionStore.listAllSessions(workingDirs);
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to load sessions", error, stackTrace);
@@ -70,6 +77,7 @@ class SessionProvider extends ChangeNotifier {
Future<void> createNewSession({
String? workingDirectory,
String? name,
String? model,
}) async {
try {
const uuid = Uuid();
@@ -86,6 +94,7 @@ class SessionProvider extends ChangeNotifier {
normalizedDirectory == null || normalizedDirectory.isEmpty
? null
: normalizedDirectory,
model: model,
);
await _sessionStore.saveSession(newSession);
@@ -101,29 +110,38 @@ class SessionProvider extends ChangeNotifier {
}
}
Future<void> loadSession(String id) async {
Future<void> loadSession(SessionSummary summary) async {
try {
final session = await _sessionStore.loadSession(id);
final workingDir = summary.workingDirectory;
if (workingDir == null || workingDir.isEmpty) return;
final session = await _sessionStore.loadSession(
summary.id,
workingDirectory: workingDir,
);
if (session != null) {
_conversationHistory.setSession(session);
_currentSession = session;
_currentSessionId = id;
_currentSessionId = summary.id;
_activeWorkingDirectory = session.workingDirectory;
notifyListeners();
}
} catch (error, stackTrace) {
_logException("Failed to load session $id", error, stackTrace);
_logException("Failed to load session ${summary.id}", error, stackTrace);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = null;
}
}
Future<void> deleteSession(String id) async {
Future<void> deleteSession(SessionSummary summary) async {
try {
await _sessionStore.deleteSession(id);
final workingDir = summary.workingDirectory;
if (workingDir == null || workingDir.isEmpty) return;
if (_currentSessionId == id) {
await _sessionStore.deleteSession(summary.id, workingDirectory: workingDir);
if (_currentSessionId == summary.id) {
_conversationHistory.setSession(
ConversationSession(
id: "",
@@ -140,7 +158,7 @@ class SessionProvider extends ChangeNotifier {
await _loadSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to delete session $id", error, stackTrace);
_logException("Failed to delete session ${summary.id}", error, stackTrace);
}
}
@@ -152,6 +170,15 @@ class SessionProvider extends ChangeNotifier {
}
}
// Updates the model on the current in-memory session and persists it
Future<void> updateSessionModel(String model) async {
final session = _currentSession;
if (session == null) return;
session.model = model;
await _sessionStore.saveSession(session);
}
ConversationHistory getConversationHistory() => _conversationHistory;
void _logException(String message, Object error, StackTrace stackTrace) {
+70 -9
View File
@@ -1,16 +1,31 @@
import "package:flutter/foundation.dart";
import "../../src/local_state.dart";
import "../../src/project_settings_store.dart";
class SettingsProvider extends ChangeNotifier {
SettingsProvider(this._settingsStore) : settings = _settingsStore.settings;
SettingsProvider(this._settingsStore) : _globalSettings = _settingsStore.settings;
static const Map<String, String> _legacyModelAliases = {
"google/gemini-2.0-flash": "google/gemini-2.0-flash-001",
};
final SettingsStore _settingsStore;
LocalSettings settings;
LocalSettings _globalSettings;
LocalSettings? _projectSettings;
String? _threadModel;
String? _activeProjectDir;
// Effective settings: global → project override → thread model
LocalSettings get settings {
var merged = _globalSettings.mergeWith(_projectSettings);
if (_threadModel != null && _threadModel!.isNotEmpty) {
merged = merged.copyWith(model: _threadModel);
}
return merged;
}
String normalizeModelId(String? modelId) {
if (modelId == null || modelId.isEmpty) {
@@ -20,12 +35,36 @@ class SettingsProvider extends ChangeNotifier {
return _legacyModelAliases[modelId] ?? modelId;
}
// Called when the active project changes
Future<void> setActiveProject(String? workingDirectory) async {
_activeProjectDir = workingDirectory;
_projectSettings = null;
_threadModel = null;
if (workingDirectory != null && workingDirectory.isNotEmpty) {
_projectSettings = await ProjectSettingsStore.instance.load(workingDirectory);
}
notifyListeners();
}
// Called when a thread is loaded or cleared
void setThreadModel(String? model) {
_threadModel = model != null ? normalizeModelId(model) : null;
notifyListeners();
}
Future<void> updateModel(String newModel) async {
final normalizedModel = normalizeModelId(newModel);
final normalized = normalizeModelId(newModel);
// update thread model in memory
_threadModel = normalized;
// also persist to global settings as the new default
await _settingsStore.update(
(current) => current.copyWith(model: normalizedModel),
(current) => current.copyWith(model: normalized),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
@@ -33,13 +72,13 @@ class SettingsProvider extends ChangeNotifier {
await _settingsStore.update(
(current) => current.copyWith(openRouterApiKey: newKey),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateTheme(String newTheme) async {
await _settingsStore.update((current) => current.copyWith(theme: newTheme));
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
@@ -47,13 +86,35 @@ class SettingsProvider extends ChangeNotifier {
await _settingsStore.update(
(current) => current.copyWith(effortLevel: newLevel),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> addAlwaysAllowRule(String toolName) async {
final current = _globalSettings.alwaysAllowRules;
if (current.contains(toolName)) return;
await _settingsStore.update(
(s) => s.copyWith(alwaysAllowRules: [...current, toolName]),
);
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> resetToDefaults() async {
await _settingsStore.update((_) => const LocalSettings());
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
_projectSettings = null;
_threadModel = null;
notifyListeners();
}
// Save project-level settings override
Future<void> updateProjectSetting(LocalSettings projectOverride) async {
final dir = _activeProjectDir;
if (dir == null || dir.isEmpty) return;
await ProjectSettingsStore.instance.save(dir, projectOverride);
_projectSettings = projectOverride;
notifyListeners();
}
}
+22
View File
@@ -0,0 +1,22 @@
import "package:go_router/go_router.dart";
import "../pages/home_screen/page.dart";
import "../pages/settings/page.dart";
import "../pages/project_detail/page.dart";
/// Application router configuration
class AppRouter {
/// List of all routes in the application
static final routes = [
HomeScreenRoute.route,
SettingsRoute.route,
ProjectDetailRoute.route,
];
/// The main GoRouter instance
static final GoRouter router = GoRouter(
routes: routes,
initialLocation: HomeScreenRoute.path,
debugLogDiagnostics: true,
);
}
+19
View File
@@ -0,0 +1,19 @@
String formatRelativeTime(DateTime timestamp) {
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
if (difference.inMinutes < 1) {
return "Just now";
}
if (difference.inHours < 1) {
return "${difference.inMinutes}m";
}
if (difference.inDays < 1) {
return "${difference.inHours}h";
}
if (difference.inDays < 7) {
return "${difference.inDays}d";
}
final weeks = difference.inDays ~/ 7;
return "${weeks}w";
}
+16
View File
@@ -0,0 +1,16 @@
import "package:path/path.dart" as p;
String shortenPath(String fullPath, String? projectRoot) {
if (projectRoot == null || projectRoot.isEmpty) return fullPath;
final root = p.normalize(projectRoot);
final norm = p.normalize(fullPath);
if (norm.startsWith(root)) {
final rel = norm.substring(root.length);
// trim leading separator
return rel.startsWith(p.separator) ? rel.substring(1) : rel;
}
return fullPath;
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
class AgentsPane extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: OutlinedContainer(
width: 300,
child: Column(
children: [
],
)
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import "package:gpt_markdown/gpt_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class AdvisorMessage extends StatelessWidget {
const AdvisorMessage({super.key, required this.title, required this.body});
final String title;
final String body;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OutlinedContainer(
padding: const EdgeInsets.all(10),
backgroundColor: theme.colorScheme.primary,
child: Icon(LucideIcons.brain).iconSmall,
),
Gap(8),
Text(
title,
style: theme.typography.p.copyWith(fontSize: 13),
),
],
),
if (body.isNotEmpty) ...[
Gap(8),
OutlinedContainer(
padding: const EdgeInsets.all(12),
child: GptMarkdown(body),
),
],
],
);
}
}
+143
View File
@@ -0,0 +1,143 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import '../../models/attachment.dart';
import '../common/button.dart';
class AttachmentPreview extends StatelessWidget {
final List<Attachment> attachments;
final Function(int) onRemove;
const AttachmentPreview({
required this.attachments,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
if (attachments.isEmpty) {
return SizedBox.shrink();
}
return MouseRegion(
cursor: SystemMouseCursors.basic,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < attachments.length; i++)
Padding(
padding: EdgeInsets.only(right: 8),
child: AttachmentItem(
attachment: attachments[i],
onRemove: () => onRemove(i),
),
),
],
),
),
),
);
}
}
class AttachmentItem extends StatelessWidget {
final Attachment attachment;
final VoidCallback onRemove;
const AttachmentItem({
required this.attachment,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
String sanitisedName = attachment.displayName;
String type = attachment.mimeType.split("/").last.toUpperCase();
return OutlinedContainer(
height: 52,
borderRadius: Theme.of(context).borderRadiusSm,
padding: EdgeInsets.all(8),
borderColor: Theme.of(context).colorScheme.border,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
OutlinedContainer(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusSm - 4
),
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.zero,
child: _buildPreview(context),
),
),
),
Gap(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
sanitisedName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small.semiBold,
Gap(2),
Text(
type,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).extraLight.small,
],
),
Gap(8),
SizedBox(
child: ClipRRect(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusSm - 4
),
child: AspectRatio(
aspectRatio: 1,
child: AgcGhostButton(
onPressed: onRemove,
child: Icon(LucideIcons.x, size: 14),
),
),
),
)
],
),
);
}
Widget _buildPreview(BuildContext context) {
if (attachment.isImage) {
return Image.memory(
attachment.data,
fit: BoxFit.cover,
);
}
final icon = _getIconForMimeType(attachment.mimeType);
return Container(
color: Theme.of(context).colorScheme.muted,
child: Icon(icon).iconMedium,
);
}
IconData _getIconForMimeType(String mimeType) {
if (mimeType == 'application/pdf') {
return LucideIcons.book;
} else if (mimeType.startsWith('text/') || mimeType == 'application/json') {
return LucideIcons.fileText;
} else if (mimeType.startsWith('image/')) {
return LucideIcons.image;
} else {
return LucideIcons.file;
}
}
}
@@ -0,0 +1,13 @@
import "package:gpt_markdown/gpt_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class AssistantBubble extends StatelessWidget {
const AssistantBubble({super.key, required this.content});
final String content;
@override
Widget build(BuildContext context) {
return GptMarkdown(content);
}
}
@@ -0,0 +1 @@
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
@@ -0,0 +1,89 @@
import "dart:convert";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tools/advisor_bubble.dart";
import "tools/bash_bubble.dart";
import "tools/default_tool_bubble.dart";
import "tools/edit_bubble.dart";
import "tools/glob_bubble.dart";
import "tools/grep_bubble.dart";
import "tools/read_bubble.dart";
import "tools/web_fetch_bubble.dart";
import "tools/web_search_bubble.dart";
import "tools/write_bubble.dart";
class ToolBubble extends StatelessWidget {
const ToolBubble({
super.key,
required this.toolName,
this.toolInput,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final Map<String, dynamic>? toolInput;
final String? result;
final bool isPendingPermission;
// parse a tool message content string into (toolName, toolInput)
// format: "$toolName call\n{json}" or "$toolName result\n..."
static (String, Map<String, dynamic>?) parseContent(String content) {
final newlineIdx = content.indexOf("\n");
if (newlineIdx == -1) {
// no body, just a label line
final name = _extractName(content);
return (name, null);
}
final firstLine = content.substring(0, newlineIdx).trim();
final rest = content.substring(newlineIdx + 1).trim();
final name = _extractName(firstLine);
if (firstLine.endsWith(" call") && rest.isNotEmpty) {
try {
final decoded = jsonDecode(rest);
if (decoded is Map<String, dynamic>) {
return (name, decoded);
}
} catch (_) {}
}
return (name, null);
}
static String _extractName(String line) {
// strip trailing " call" or " result"
if (line.endsWith(" call")) return line.substring(0, line.length - 5).trim();
if (line.endsWith(" result")) return line.substring(0, line.length - 7).trim();
return line.trim();
}
@override
Widget build(BuildContext context) {
final input = toolInput ?? {};
switch (toolName) {
case "Bash":
return BashBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Edit":
return EditBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Read":
return ReadBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Write":
return WriteBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Glob":
return GlobBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Grep":
return GrepBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "WebSearch":
return WebSearchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "WebFetch":
return WebFetchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Advisor":
return AdvisorBubble(input: input, result: result, isPendingPermission: isPendingPermission);
default:
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, isPendingPermission: isPendingPermission);
}
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class AdvisorBubble extends StatelessWidget {
const AdvisorBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final model = input["model"] as String? ?? "";
return ToolBubbleBase(
toolName: "Advisor",
icon: LucideIcons.brain,
result: result,
isPendingPermission: isPendingPermission,
detail: model,
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class BashBubble extends StatelessWidget {
const BashBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final command = input["command"] as String? ?? "";
return ToolBubbleBase(
toolName: "Bash",
icon: LucideIcons.terminal,
result: result,
isPendingPermission: isPendingPermission,
detail: command,
);
}
}
@@ -0,0 +1,43 @@
import "dart:convert";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class DefaultToolBubble extends StatelessWidget {
const DefaultToolBubble({
super.key,
required this.toolName,
this.input,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final Map<String, dynamic>? input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ToolBubbleBase(
toolName: toolName,
icon: LucideIcons.wrench,
result: result,
isPendingPermission: isPendingPermission,
body: input != null && input!.isNotEmpty
? Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
const JsonEncoder.withIndent(" ").convert(input),
style: theme.typography.p.copyWith(
fontSize: 12,
color: theme.colorScheme.mutedForeground,
fontFamily: "monospace",
),
),
)
: null,
);
}
}
@@ -0,0 +1,39 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "../../diff_view.dart";
import "tool_bubble_base.dart";
class EditBubble extends StatelessWidget {
const EditBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
final oldString = input["old_string"] as String? ?? "";
final newString = input["new_string"] as String? ?? "";
return ToolBubbleBase(
toolName: "Edit",
icon: LucideIcons.filePen,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
body: DiffView(
oldString: oldString,
newString: newString,
),
);
}
}
@@ -0,0 +1,37 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class GlobBubble extends StatelessWidget {
const GlobBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final pattern = input["pattern"] as String? ?? "";
final searchPath = input["path"] as String?;
final detail = searchPath != null && searchPath.isNotEmpty
? "${shortenPath(searchPath, projectRoot)}/$pattern"
: pattern;
return ToolBubbleBase(
toolName: "Glob",
icon: LucideIcons.folderSearch,
result: result,
isPendingPermission: isPendingPermission,
detail: detail,
);
}
}
@@ -0,0 +1,37 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class GrepBubble extends StatelessWidget {
const GrepBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final pattern = input["pattern"] as String? ?? "";
final searchPath = input["path"] as String?;
final detail = searchPath != null && searchPath.isNotEmpty
? "${shortenPath(searchPath, projectRoot)}$pattern"
: pattern;
return ToolBubbleBase(
toolName: "Grep",
icon: LucideIcons.search,
result: result,
isPendingPermission: isPendingPermission,
detail: detail,
);
}
}
@@ -0,0 +1,32 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class ReadBubble extends StatelessWidget {
const ReadBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
return ToolBubbleBase(
toolName: "Read",
icon: LucideIcons.fileText,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
);
}
}
@@ -0,0 +1,145 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../permission_decision.dart";
class ToolBubbleBase extends StatelessWidget {
const ToolBubbleBase({
super.key,
required this.toolName,
required this.icon,
this.detail,
this.body,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final IconData icon;
final String? detail;
final Widget? body;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedContainer(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IntrinsicHeight(
child: Row(
children: [
Container(
color: theme.colorScheme.primary.scaleAlpha(0.5),
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
children: [
Icon(icon).iconSmall,
Gap(8),
Text(
toolName,
).textSmall,
],
),
),
VerticalDivider(),
if (detail != null)...[
Gap(16),
Text(
detail!,
).mono.xSmall
]
],
),
),
if (body != null) ...[
Divider(),
body!,
],
if (result != null) ...[
Divider(),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: SelectableText(
"\u200B${result!}",
style: TextStyle(
color: theme.colorScheme.mutedForeground,
),
).xSmall.mono,
)
]
],
),
),
if (isPendingPermission) ...[
Gap(8),
Row(
children: [
Expanded(
child: Button.outline(
leading: Icon(LucideIcons.check).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
child: Text("Allow").small,
),
),
Gap(8),
Expanded(
child: Button.outline(
leading: Icon(LucideIcons.checkCheck).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
child: Text("Allow always").small,
),
),
Gap(8),
Expanded(
child: Button.destructive(
leading: Icon(LucideIcons.x).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
child: Text("Reject").small,
),
),
],
),
],
],
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class WebFetchBubble extends StatelessWidget {
const WebFetchBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final url = input["url"] as String? ?? "";
return ToolBubbleBase(
toolName: "WebFetch",
icon: LucideIcons.link,
result: result,
isPendingPermission: isPendingPermission,
detail: url,
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class WebSearchBubble extends StatelessWidget {
const WebSearchBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final query = input["query"] as String? ?? "";
return ToolBubbleBase(
toolName: "WebSearch",
icon: LucideIcons.globe,
result: result,
isPendingPermission: isPendingPermission,
detail: query,
);
}
}
@@ -0,0 +1,38 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "../../diff_view.dart";
import "tool_bubble_base.dart";
class WriteBubble extends StatelessWidget {
const WriteBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
final content = input["content"] as String? ?? "";
return ToolBubbleBase(
toolName: "Write",
icon: LucideIcons.filePlus,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
body: DiffView(
oldString: "",
newString: content,
),
);
}
}
@@ -0,0 +1,19 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class UserBubble extends StatelessWidget {
const UserBubble({super.key, required this.content});
final String content;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: Theme.of(context).colorScheme.border,
child: SelectableText(content),
),
);
}
}
+455
View File
@@ -0,0 +1,455 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import 'package:pasteboard/pasteboard.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'package:provider/provider.dart';
import 'dart:io';
import '../../constants.dart';
import '../../models/attachment.dart';
import '../../providers/chat_provider.dart';
import '../../providers/home_coordinator.dart';
import '../../providers/session_provider.dart';
import '../../providers/settings_provider.dart';
import 'attachment_preview.dart';
import '../common/button.dart';
import 'model_picker_dialog.dart';
class ChatBox extends StatefulWidget {
const ChatBox({super.key});
@override
State<ChatBox> createState() => _ChatBoxState();
}
class _ChatBoxState extends State<ChatBox> {
late TextEditingController _controller;
late FocusNode _focusNode;
final List<Attachment> _attachments = [];
@override
void initState() {
super.initState();
_controller = TextEditingController();
_focusNode = FocusNode();
_controller.addListener(_onTextChanged);
}
Future<void> _onPastePressed() async {
try {
final filePaths = await Pasteboard.files();
if (filePaths.isNotEmpty && mounted) {
for (var filePath in filePaths) {
try {
final file = File(filePath);
final fileBytes = await file.readAsBytes();
final fileName = file.path.split('/').last;
if (mounted) {
setState(() {
_attachments.add(
Attachment(
name: fileName,
mimeType: _getMimeType(fileName, fileBytes),
data: fileBytes,
),
);
});
}
} catch (e) {
// skip files that cant be read
}
}
return;
}
} catch (e) {
// no files in clipboard
}
// fallback to raw image data (screenshots etc)
try {
final imageBytes = await Pasteboard.image;
if (imageBytes != null && mounted) {
final imageData = Uint8List.fromList(imageBytes);
setState(() {
_attachments.add(
Attachment(
name: 'image.png',
mimeType: _getMimeType('image.png', imageData),
data: imageData,
),
);
});
}
} catch (e) {
// no image in clipboard
}
}
String _getMimeType(String filename, Uint8List data) {
if (data.length >= 4) {
if (data[0] == 0x25 &&
data[1] == 0x50 &&
data[2] == 0x44 &&
data[3] == 0x46) {
return 'application/pdf';
}
if (data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47) {
return 'image/png';
}
if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) {
return 'image/jpeg';
}
if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46) {
return 'image/gif';
}
if (data[0] == 0x52 &&
data[1] == 0x49 &&
data[2] == 0x46 &&
data[3] == 0x46) {
if (data.length >= 12 &&
data[8] == 0x57 &&
data[9] == 0x45 &&
data[10] == 0x42 &&
data[11] == 0x50) {
return 'image/webp';
}
}
}
final extension = filename.split('.').last.toLowerCase();
switch (extension) {
case 'pdf':
return 'application/pdf';
case 'txt':
return 'text/plain';
case 'json':
return 'application/json';
case 'csv':
return 'text/csv';
case 'md':
return 'text/markdown';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
void _onTextChanged() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
Future<void> _onAttachPressed() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null || !mounted) return;
for (final file in result.files) {
if (file.path == null) continue;
try {
final f = File(file.path!);
final bytes = await f.readAsBytes();
if (!mounted) return;
setState(() {
_attachments.add(
Attachment(
name: file.name,
mimeType: _getMimeType(file.name, bytes),
data: bytes,
),
);
});
} catch (e) {
// skip unreadable files
}
}
}
Widget _left(BuildContext context) {
return SizedBox(
height: 38,
child: AspectRatio(
aspectRatio: 1,
child: AgcGhostButton(
borderRadius: BorderRadius.circular(Theme.of(context).radiusLg - 4),
onPressed: _onAttachPressed,
child: Icon(LucideIcons.paperclip),
),
),
);
}
void _openModelDialog(BuildContext context) async {
final settings = context.read<SettingsProvider>();
final session = context.read<SessionProvider>();
final selectedModel = settings.normalizeModelId(settings.settings.model);
final result = await showDialog<String>(
context: context,
builder: (context) => ModelPickerDialog(
models: selectableAiModels,
selectedModel: selectedModel,
),
);
if (result != null) {
await settings.updateModel(result);
await session.updateSessionModel(result);
}
}
Widget _right(BuildContext context) {
final settings = context.read<SettingsProvider>();
final selectedModel = settings.normalizeModelId(settings.settings.model);
return SizedBox(
height: 38,
child: Row(
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 150,
minHeight: double.infinity,
),
child: AgcGhostButton(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusLg - 4,
),
onPressed: () => _openModelDialog(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
selectableAiModels
.where((m) => m.id == selectedModel)
.map((m) => m.label)
.firstOrNull ??
selectedModel,
overflow: TextOverflow.ellipsis,
).small,
),
Gap(8),
Icon(LucideIcons.chevronsUpDown),
],
),
),
),
),
Gap(8),
AspectRatio(
aspectRatio: 1,
child: AgcSecondaryButton(
enabled: _controller.text.isNotEmpty,
onPressed: () {
final text = _controller.text.trim();
if (text.isEmpty) return;
context.read<HomeCoordinator>().sendMessage(text);
_controller.clear();
},
child: Icon(LucideIcons.arrowUp),
),
),
],
),
);
}
Widget _buildLeading(BuildContext context, int numberOfLines) {
if (numberOfLines > 1) return SizedBox.shrink();
return _left(context);
}
Widget _buildTrailing(int numberOfLines) {
if (numberOfLines > 1) return SizedBox.shrink();
return _right(context);
}
Widget? _buildBottom(BuildContext context, int numberOfLines) {
if (numberOfLines <= 1) return null;
return Container(
margin: EdgeInsets.only(top: 8),
height: 32,
child: Row(children: [_left(context), Spacer(), _right(context)]),
);
}
String _fmtTokens(int n) {
final s = n.toString();
final buf = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
buf.write(s[i]);
}
return buf.toString();
}
void _removeAttachment(int index) {
setState(() {
_attachments.removeAt(index);
});
_focusNode.requestFocus();
}
@override
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final chat = context.watch<ChatProvider>();
context
.watch<SettingsProvider>(); // needed so model label updates reactively
final queuedMessages = chat.queuedMessages;
final contextTokens = chat.contextTokens;
return Column(
children: [
LayoutBuilder(
builder: (context, constraints) {
final style = DefaultTextStyle.of(context).style;
const reservedForIcons = 34;
final painter = TextPainter(
text: TextSpan(text: _controller.text, style: style),
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth - reservedForIcons);
final numberOfLines = painter.computeLineMetrics().length;
return Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyV &&
(HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed)) {
_onPastePressed();
}
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter) {
if (HardwareKeyboard.instance.isShiftPressed) {
final sel = _controller.selection;
final text = _controller.text;
final newText = text.replaceRange(sel.start, sel.end, '\n');
_controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: sel.start + 1),
);
return KeyEventResult.handled;
} else {
final text = _controller.text.trim();
if (text.isNotEmpty) {
context.read<HomeCoordinator>().sendMessage(text);
_controller.clear();
}
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: OutlinedContainer(
child: ButtonGroup.vertical(
expands: true,
children: [
for (int i = 0; i < queuedMessages.length; i++) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 0,
),
child: Row(
children: [
Icon(
LucideIcons.cornerDownRight,
).iconSmall.iconMutedForeground,
Gap(14),
Expanded(
child: Text(queuedMessages[i]).small.textMuted,
),
IconButton.text(
onPressed: () => chat.removeQueuedMessage(i),
icon: const Icon(LucideIcons.trash2),
).iconSmall,
],
),
),
Divider(),
],
TextField(
controller: _controller,
focusNode: _focusNode,
borderRadius: Theme.of(context).borderRadiusLg,
placeholder: Text("Ask the agency anything"),
minLines: 1,
maxLines: numberOfLines > 1 ? 5 : 1,
clipBehavior: Clip.hardEdge,
padding: EdgeInsets.all(8),
features: [
if (_attachments.isNotEmpty)
InputFeature.above(
AttachmentPreview(
attachments: _attachments,
onRemove: _removeAttachment,
),
),
InputFeature.leading(
_buildLeading(context, numberOfLines),
),
InputFeature.trailing(_buildTrailing(numberOfLines)),
InputFeature.below(
_buildBottom(context, numberOfLines),
),
],
),
if (chat.isLoading)
SizedBox(
height: 4,
child: LinearProgressIndicator()
)
],
),
),
);
},
),
],
);
}
}
+383
View File
@@ -0,0 +1,383 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../src/session/session_types.dart";
import "../../providers/chat_provider.dart";
import "bubbles/assistant_bubble.dart";
import "bubbles/tool_bubble.dart";
import "bubbles/user_bubble.dart";
class ChatView extends StatefulWidget {
final ScrollController scrollController;
const ChatView({super.key, required this.scrollController});
@override
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> {
ScrollController get _scrollController => widget.scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
}
@override
void dispose() {
_scrollController.removeListener(_handleScroll);
super.dispose();
}
void _handleScroll() {
_lastScrollTime = DateTime.now();
_isUserScrolling = true;
if (_scrollController.hasClients) {
final position = _scrollController.position;
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
if (isFarFromBottom != _showJumpToBottom) {
setState(() {
_showJumpToBottom = isFarFromBottom;
});
}
if (!isFarFromBottom) {
setState(() {
_hasNewMessagesWhileScrolledAway = false;
});
}
}
Future.delayed(const Duration(milliseconds: 150), () {
if (_lastScrollTime != null &&
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
if (mounted) {
setState(() {
_isUserScrolling = false;
});
}
}
});
}
bool _isNearBottom() {
if (!_scrollController.hasClients) return false;
final position = _scrollController.position;
return position.pixels >= position.maxScrollExtent - 150;
}
void _jumpToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
setState(() {
_showJumpToBottom = false;
_hasNewMessagesWhileScrolledAway = false;
});
}
}
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
final currentMessages = chatProvider.messages;
bool messagesChanged = false;
if (currentMessages.length != _previousMessageContents.length) {
messagesChanged = true;
} else {
for (int i = 0; i < currentMessages.length; i++) {
if (currentMessages[i].content != _previousMessageContents[i]) {
messagesChanged = true;
break;
}
}
}
if (messagesChanged && currentMessages.isNotEmpty) {
final nearBottom = _isNearBottom();
if (nearBottom && !_isUserScrolling) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
_hasNewMessagesWhileScrolledAway = false;
} else if (!nearBottom) {
_hasNewMessagesWhileScrolledAway = true;
}
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_previousMessageContents = currentMessages.map((m) => m.content).toList();
});
final entries = _buildEntries(currentMessages);
return Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final pending = chatProvider.pendingPermission;
final isThisPending = pending != null &&
index == entries.length - 1 &&
entry is _ToolEntry &&
entry.toolName == pending.toolName;
Widget bubble;
if (entry is _MessageEntry) {
final msg = entry.message;
if (msg.role == "user") {
bubble = UserBubble(content: msg.content);
} else if (msg.role == "assistant") {
bubble = AssistantBubble(content: msg.content);
} else {
bubble = Text(msg.content);
}
} else if (entry is _ToolEntry) {
bubble = ToolBubble(
toolName: entry.toolName,
toolInput: entry.toolInput,
result: entry.result,
isPendingPermission: isThisPending,
);
} else {
bubble = const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: bubble,
);
},
),
),
),
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
Positioned(
bottom: 16,
right: 16,
child: GestureDetector(
onTap: _jumpToBottom,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xFF000000).withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: Theme.of(context).colorScheme.foreground,
),
const SizedBox(width: 6),
Text(
"New messages",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.foreground,
),
),
],
),
),
),
),
],
);
},
);
}
// merge consecutive tool call + result messages into single entries
List<_ChatEntry> _buildEntries(List<Message> messages) {
final result = <_ChatEntry>[];
int i = 0;
while (i < messages.length) {
final msg = messages[i];
if (msg.role == "tool") {
final firstLine = msg.content.split("\n").first.trim();
if (firstLine.endsWith(" call")) {
final (toolName, toolInput) = ToolBubble.parseContent(msg.content);
// check if next message is the matching result
String? toolResult;
if (i + 1 < messages.length) {
final next = messages[i + 1];
final nextFirst = next.content.split("\n").first.trim();
if (next.role == "tool" && nextFirst == "$toolName result") {
final body = next.content.indexOf("\n");
toolResult = body != -1 ? next.content.substring(body + 1).trim() : null;
i++;
}
}
result.add(_ToolEntry(
toolName: toolName,
toolInput: toolInput,
result: toolResult,
));
i++;
continue;
}
// orphan result or unknown tool message — skip it
// (already consumed as part of a call above, or genuinely standalone)
final (toolName, _) = ToolBubble.parseContent(msg.content);
result.add(_ToolEntry(toolName: toolName));
i++;
} else {
result.add(_MessageEntry(msg));
i++;
}
}
return result;
}
}
sealed class _ChatEntry {}
class _MessageEntry extends _ChatEntry {
_MessageEntry(this.message);
final Message message;
}
class _ToolEntry extends _ChatEntry {
_ToolEntry({required this.toolName, this.toolInput, this.result});
final String toolName;
final Map<String, dynamic>? toolInput;
final String? result;
}
class FullHeightScrollbar extends StatefulWidget {
final ScrollController controller;
const FullHeightScrollbar({super.key, required this.controller});
@override
State<FullHeightScrollbar> createState() => _FullHeightScrollbarState();
}
class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
bool _hovering = false;
bool _scrolling = false;
DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
widget.controller.addListener(_onScroll);
}
void _onScroll() {
_lastScroll = DateTime.now();
setState(() => _scrolling = true);
Future.delayed(const Duration(milliseconds: 800), () {
if (!mounted) return;
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) {
setState(() => _scrolling = false);
}
});
}
@override
void dispose() {
widget.controller.removeListener(_onScroll);
super.dispose();
}
@override
Widget build(BuildContext context) {
final visible = _hovering || _scrolling;
return MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
final totalHeight = constraints.maxHeight;
if (!widget.controller.hasClients) return const SizedBox.shrink();
final pos = widget.controller.position;
final maxScroll = pos.maxScrollExtent;
if (maxScroll <= 0) return const SizedBox.shrink();
final viewportFraction = pos.viewportDimension / (pos.viewportDimension + maxScroll);
final thumbHeight = (viewportFraction * totalHeight).clamp(32.0, totalHeight);
final scrollFraction = pos.pixels / maxScroll;
final thumbTop = scrollFraction * (totalHeight - thumbHeight);
final color = Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.4);
return Stack(
children: [
Positioned(
top: thumbTop,
left: 2,
right: 2,
height: thumbHeight,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
),
],
);
},
),
),
);
}
}
+329
View File
@@ -0,0 +1,329 @@
import "package:diff_match_patch/diff_match_patch.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
const _contextLines = 3;
class DiffView extends StatelessWidget {
const DiffView({
super.key,
this.oldString,
this.newString,
this.content,
}) : assert(
content != null || (oldString != null && newString != null),
"Provide either content (view-only) or oldString+newString (diff)",
);
final String? oldString;
final String? newString;
// view-only mode — show content as plain code, no diff colors
final String? content;
@override
Widget build(BuildContext context) {
if (content != null) {
final lines = content!.split("\n");
final viewLines = [
for (int i = 0; i < lines.length; i++)
_DiffLine(_LineKind.context, lines[i], newLine: i + 1),
];
final hunk = _Hunk(oldStart: 1, newStart: 1, lines: viewLines);
return _HunkView(hunk: hunk);
}
final hunks = _computeHunks(oldString!, newString!);
if (hunks.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final hunk in hunks) ...[
// is first
if (hunk != hunks.first) ...[
Divider(),
Gap(1),
Divider(),
],
_HunkView(hunk: hunk)
],
],
);
}
}
// ─── data model ───────────────────────────────────────────────────────────────
enum _LineKind { context, added, removed }
class _DiffLine {
const _DiffLine(this.kind, this.text, {this.oldLine, this.newLine});
final _LineKind kind;
final String text;
final int? oldLine;
final int? newLine;
}
class _Hunk {
_Hunk({
required this.oldStart,
required this.newStart,
required this.lines,
});
final int oldStart;
final int newStart;
final List<_DiffLine> lines;
int get oldCount => lines.where((l) => l.kind != _LineKind.added).length;
int get newCount => lines.where((l) => l.kind != _LineKind.removed).length;
}
// ─── diff computation ─────────────────────────────────────────────────────────
List<_Hunk> _computeHunks(String oldStr, String newStr) {
final dmp = DiffMatchPatch();
final oldLines = oldStr.split("\n");
final newLines = newStr.split("\n");
// encode lines → single chars so dmp does line-level diff
final enc = _encodeLines(oldLines, newLines);
final diffs = dmp.diff(enc.oldEncoded, enc.newEncoded, false);
dmp.diffCleanupSemantic(diffs);
// expand diffs back to line sequences
final rawLines = <_DiffLine>[];
int oldIdx = 0;
int newIdx = 0;
for (final d in diffs) {
final count = d.text.length; // each char == one line
switch (d.operation) {
case DIFF_EQUAL:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.context,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
oldLine: oldIdx + 1,
newLine: newIdx + 1,
));
oldIdx++;
newIdx++;
}
break;
case DIFF_DELETE:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.removed,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
oldLine: oldIdx + 1,
));
oldIdx++;
}
break;
case DIFF_INSERT:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.added,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
newLine: newIdx + 1,
));
newIdx++;
}
break;
}
}
return _groupIntoHunks(rawLines);
}
// keep only context lines that are within _contextLines of a change
List<_Hunk> _groupIntoHunks(List<_DiffLine> rawLines) {
final n = rawLines.length;
// mark which context lines to keep
final keep = List<bool>.filled(n, false);
for (int i = 0; i < n; i++) {
if (rawLines[i].kind != _LineKind.context) {
for (int j = (i - _contextLines).clamp(0, n - 1);
j <= (i + _contextLines).clamp(0, n - 1);
j++) {
keep[j] = true;
}
}
}
final hunks = <_Hunk>[];
int i = 0;
while (i < n) {
if (!keep[i]) {
i++;
continue;
}
// start of a new hunk
final hunkLines = <_DiffLine>[];
int oldStart = rawLines[i].oldLine ?? 1;
int newStart = rawLines[i].newLine ?? 1;
while (i < n && keep[i]) {
hunkLines.add(rawLines[i]);
i++;
}
hunks.add(_Hunk(
oldStart: oldStart,
newStart: newStart,
lines: hunkLines,
));
}
return hunks;
}
// line encoding — maps unique lines to single unicode chars starting at U+E000
class _LineEncoding {
final List<String> lines; // index → line text
final String oldEncoded;
final String newEncoded;
const _LineEncoding(this.lines, this.oldEncoded, this.newEncoded);
}
_LineEncoding _encodeLines(List<String> oldLines, List<String> newLines) {
final lineIndex = <String, int>{};
final lines = <String>[];
String encode(List<String> src) {
final buf = StringBuffer();
for (final line in src) {
if (!lineIndex.containsKey(line)) {
lineIndex[line] = lines.length;
lines.add(line);
}
buf.writeCharCode(0xE000 + lineIndex[line]!);
}
return buf.toString();
}
final oldEncoded = encode(oldLines);
final newEncoded = encode(newLines);
return _LineEncoding(lines, oldEncoded, newEncoded);
}
// ─── widgets ──────────────────────────────────────────────────────────────────
String _hunkSummary(_Hunk hunk) {
final added = hunk.lines.where((l) => l.kind == _LineKind.added).length;
final removed = hunk.lines.where((l) => l.kind == _LineKind.removed).length;
final parts = <String>[];
if (added > 0) parts.add("Added $added ${added == 1 ? 'line' : 'lines'}");
if (removed > 0) parts.add("removed $removed ${removed == 1 ? 'line' : 'lines'}");
return parts.join(", ");
}
class _HunkView extends StatelessWidget {
const _HunkView({required this.hunk});
final _Hunk hunk;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// hunk header
Container(
// color: theme.colorScheme.muted.withValues(alpha: 0.4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
_hunkSummary(hunk),
style: TextStyle(color: theme.colorScheme.mutedForeground),
).xSmall.mono,
),
Divider(),
for (final line in hunk.lines) _LineView(line: line),
],
);
}
}
class _LineView extends StatelessWidget {
const _LineView({required this.line});
final _DiffLine line;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Color bg;
final Color fg;
final String prefix;
switch (line.kind) {
case _LineKind.added:
bg = const Color(0xFF166534).withValues(alpha: 0.2);
fg = const Color(0xFF4ADE80);
prefix = "+";
break;
case _LineKind.removed:
bg = const Color(0xFF991B1B).withValues(alpha: 0.2);
fg = const Color(0xFFF87171);
prefix = "-";
break;
case _LineKind.context:
bg = Colors.transparent;
fg = theme.colorScheme.mutedForeground;
prefix = " ";
break;
}
final numColor = theme.colorScheme.mutedForeground.withValues(alpha: 0.5);
final lineNum = line.kind == _LineKind.removed ? line.oldLine : line.newLine;
return Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 32,
child: Text(
lineNum != null ? "$lineNum" : "",
textAlign: TextAlign.right,
style: TextStyle(color: numColor),
).mono.xSmall,
),
const SizedBox(width: 8),
SizedBox(
width: 10,
child: Text(prefix, style: TextStyle(color: fg)).mono.xSmall,
),
const SizedBox(width: 4),
Expanded(
child: Text(line.text, style: TextStyle(color: fg)).mono.xSmall,
),
],
),
);
}
}
@@ -2,12 +2,21 @@ import "package:flutter/src/material/theme_data.dart";
import "package:flutter_markdown/flutter_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../src/session/session_types.dart";
import "../../../src/permissions/permission_types.dart";
import "../../../src/session/session_types.dart";
import "advisor_message.dart";
import "../common/button.dart";
class MessageBubble extends StatelessWidget {
const MessageBubble({required this.message});
const MessageBubble({
required this.message,
this.isPendingPermission = false,
this.onPermissionDecision,
});
final Message message;
final bool isPendingPermission;
final void Function(PermissionDecision)? onPermissionDecision;
@override
Widget build(BuildContext context) {
@@ -19,23 +28,21 @@ class MessageBubble extends StatelessWidget {
if (isUser) {
return Row(
children: [
Spacer(),
OutlinedContainer(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
backgroundColor: theme.colorScheme.border,
child: MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
),
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
],
backgroundColor: theme.colorScheme.border,
child: MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
),
),
);
} else if (isAssistant) {
return MarkdownBody(
@@ -48,27 +55,66 @@ class MessageBubble extends StatelessWidget {
final lines = message.content.split("\n");
final title = lines.first.trim();
final isAdvisor = title.startsWith("Advisor");
return Row(
if (isAdvisor) {
final body = lines.skip(1).join("\n").trim();
return AdvisorMessage(title: title, body: body);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 10,
width: 10,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle
),
Row(
children: [
OutlinedContainer(
padding: const EdgeInsets.all(10),
backgroundColor: theme.colorScheme.primary,
child: Icon(LucideIcons.wrench).iconSmall,
),
Gap(8),
Expanded(
child: Text(
title,
style: theme.typography.p.copyWith(fontSize: 13),
),
),
],
),
Gap(8),
if (isPendingPermission) ...[
Gap(8),
Row(
children: [
Text(
title,
style: theme.typography.p.copyWith(
fontSize: 13
AgcSecondaryButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowOnce),
child: Text("Allow").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowAlways),
child: Text("Allow always").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.reject),
child: Text("Reject").small,
),
],
),
),
],
],
);
}
@@ -1,8 +1,8 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../../src/api/openrouter_client.dart";
import "../providers/settings_provider.dart";
import "../../../src/api/openrouter_client.dart";
import "../../providers/settings_provider.dart";
class ModelPicker extends StatefulWidget {
const ModelPicker();
@@ -0,0 +1,103 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../constants.dart";
class ModelPickerDialog extends StatefulWidget {
final List<SelectableAiModel> models;
final String? selectedModel;
const ModelPickerDialog({
super.key,
required this.models,
this.selectedModel,
});
@override
State<ModelPickerDialog> createState() => _ModelPickerDialogState();
}
class _ModelPickerDialogState extends State<ModelPickerDialog> {
late TextEditingController _searchController;
String _query = '';
@override
void initState() {
super.initState();
_searchController = TextEditingController();
_searchController.addListener(() {
setState(() => _query = _searchController.text.trim().toLowerCase());
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<SelectableAiModel> get _filtered {
if (_query.isEmpty) return widget.models;
return widget.models.where((m) =>
m.label.toLowerCase().contains(_query) ||
m.id.toLowerCase().contains(_query)
).toList();
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
return AlertDialog(
title: const Text('Select model'),
content: SizedBox(
width: 340,
height: 380,
child: Column(
children: [
TextField(
controller: _searchController,
autofocus: true,
placeholder: const Text('Search models...'),
features: const [InputFeature.clear()],
),
Gap(8),
Expanded(
child: filtered.isEmpty
? Center(child: Text("No results").muted)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, i) {
final model = filtered[i];
final isSelected = model.id == widget.selectedModel;
return SizedBox(
width: double.infinity,
child: Button(
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
disableFocusOutline: true,
onPressed: () => Navigator.of(context).pop(model.id),
child: Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(model.label),
Text(model.id).muted.small,
],
),
),
),
);
},
),
),
],
),
),
);
}
}
-205
View File
@@ -1,205 +0,0 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/chat_provider.dart";
import "message_bubble.dart";
class ChatView extends StatefulWidget {
const ChatView();
@override
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> {
late ScrollController _scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_handleScroll);
}
@override
void dispose() {
_scrollController.removeListener(_handleScroll);
_scrollController.dispose();
super.dispose();
}
void _handleScroll() {
_lastScrollTime = DateTime.now();
_isUserScrolling = true;
// Update whether to show jump-to-bottom button
if (_scrollController.hasClients) {
final position = _scrollController.position;
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
if (isFarFromBottom != _showJumpToBottom) {
setState(() {
_showJumpToBottom = isFarFromBottom;
});
}
// If user scrolls to bottom manually, clear the new messages flag
if (!isFarFromBottom) {
setState(() {
_hasNewMessagesWhileScrolledAway = false;
});
}
}
// Check if scrolling has stopped (no scroll events for 150ms)
Future.delayed(const Duration(milliseconds: 150), () {
if (_lastScrollTime != null &&
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
if (mounted) {
setState(() {
_isUserScrolling = false;
});
}
}
});
}
bool _isNearBottom() {
if (!_scrollController.hasClients) return false;
final position = _scrollController.position;
// Consider user to be "near bottom" if they're within 150 pixels of the bottom
// Add a small buffer so we don't trigger on exact bottom
return position.pixels >= position.maxScrollExtent - 150;
}
void _jumpToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
setState(() {
_showJumpToBottom = false;
_hasNewMessagesWhileScrolledAway = false;
});
}
}
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
// Get current messages
final currentMessages = chatProvider.messages;
// Check if messages have actually changed (not just re-renders)
bool messagesChanged = false;
if (currentMessages.length != _previousMessageContents.length) {
messagesChanged = true;
} else {
for (int i = 0; i < currentMessages.length; i++) {
if (i >= _previousMessageContents.length ||
currentMessages[i].content != _previousMessageContents[i]) {
messagesChanged = true;
break;
}
}
}
if (messagesChanged && currentMessages.isNotEmpty) {
// Check if we're near the bottom
final nearBottom = _isNearBottom();
if (nearBottom && !_isUserScrolling) {
// Auto-scroll to bottom if user is near bottom and not scrolling
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
_hasNewMessagesWhileScrolledAway = false;
} else if (!nearBottom) {
// User is scrolled away from bottom when new messages arrive
_hasNewMessagesWhileScrolledAway = true;
}
}
// Update previous message state for next build
WidgetsBinding.instance.addPostFrameCallback((_) {
_previousMessageContents = currentMessages.map((m) => m.content).toList();
});
return Stack(
children: [
ListView.builder(
controller: _scrollController,
itemCount: currentMessages.length,
itemBuilder: (context, index) {
final message = currentMessages[index];
return Padding(
padding: EdgeInsetsGeometry.only(
top: index != 0 ? 12 : 0
),
child: MessageBubble(message: message)
);
},
),
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
Positioned(
bottom: 16,
right: 16,
child: GestureDetector(
onTap: _jumpToBottom,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xFF000000).withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: Theme.of(context).colorScheme.foreground,
),
const SizedBox(width: 6),
Text(
"New messages",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.foreground,
),
),
],
),
),
),
),
],
);
},
);
}
}
+233
View File
@@ -0,0 +1,233 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class AgcGhostButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final BorderRadius? borderRadius;
AgcGhostButton({
required this.child,
this.onPressed,
this.borderRadius,
});
@override
State<AgcGhostButton> createState() => _GhostButtonState();
}
class _GhostButtonState extends State<AgcGhostButton> {
bool _hovering = false;
bool _pressing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final radius = widget.borderRadius ?? BorderRadius.circular(
Theme.of(context).radiusSm - 4
);
Color bg = Colors.transparent;
if (_pressing) {
bg = colorScheme.accent.withOpacity(0.8);
} else if (_hovering) {
bg = colorScheme.accent.withOpacity(0.5);
}
return MouseRegion(
cursor: widget.onPressed != null ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() {
_hovering = false;
_pressing = false;
}),
child: GestureDetector(
onTapDown: (_) => setState(() => _pressing = true),
onTapUp: (_) {
setState(() => _pressing = false);
if (widget.onPressed != null) widget.onPressed!();
},
onTapCancel: () => setState(() => _pressing = false),
child: AnimatedContainer(
duration: Duration(milliseconds: 80),
decoration: BoxDecoration(
color: bg,
borderRadius: radius,
),
padding: EdgeInsets.all(4),
child: widget.child,
),
),
);
}
}
class AgcSecondaryButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final BorderRadius? borderRadius;
final bool enabled;
AgcSecondaryButton({
required this.child,
this.onPressed,
this.borderRadius,
this.enabled = true,
});
@override
State<AgcSecondaryButton> createState() => _SecondaryButtonState();
}
class _SecondaryButtonState extends State<AgcSecondaryButton> {
bool _hovering = false;
bool _pressing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final radius = widget.borderRadius ?? BorderRadius.circular(
Theme.of(context).radiusSm
);
final bool active = widget.enabled && widget.onPressed != null;
Color bg = colorScheme.secondary;
if (!active) {
bg = colorScheme.secondary.withOpacity(0.4);
} else if (_pressing) {
bg = colorScheme.secondary.withOpacity(0.75);
} else if (_hovering) {
bg = colorScheme.secondary.withOpacity(0.85);
}
return MouseRegion(
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) { if (active) setState(() => _hovering = true); },
onExit: (_) => setState(() {
_hovering = false;
_pressing = false;
}),
child: GestureDetector(
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
onTapUp: active ? (_) {
setState(() => _pressing = false);
widget.onPressed!();
} : null,
onTapCancel: active ? () => setState(() => _pressing = false) : null,
child: AnimatedContainer(
duration: Duration(milliseconds: 80),
decoration: BoxDecoration(
color: bg,
borderRadius: radius,
),
padding: EdgeInsets.all(4),
child: DefaultTextStyle.merge(
style: TextStyle(color: colorScheme.secondaryForeground),
child: IconTheme.merge(
data: IconThemeData(color: colorScheme.secondaryForeground),
child: widget.child,
),
),
),
),
);
}
}
class AgcOutlinedButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final BorderRadius? borderRadius;
final bool enabled;
AgcOutlinedButton({
required this.child,
this.onPressed,
this.borderRadius,
this.enabled = true,
});
@override
State<AgcOutlinedButton> createState() => _OutlinedButtonState();
}
class _OutlinedButtonState extends State<AgcOutlinedButton> {
bool _hovering = false;
bool _pressing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final radius = widget.borderRadius ?? BorderRadius.circular(
Theme.of(context).radiusSm
);
final bool active = widget.enabled && widget.onPressed != null;
Color bg = Colors.transparent;
if (_pressing && active) {
bg = colorScheme.accent.withOpacity(0.6);
} else if (_hovering && active) {
bg = colorScheme.accent.withOpacity(0.35);
}
final borderColor = active
? colorScheme.border
: colorScheme.border.withOpacity(0.4);
return MouseRegion(
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) { if (active) setState(() => _hovering = true); },
onExit: (_) => setState(() {
_hovering = false;
_pressing = false;
}),
child: GestureDetector(
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
onTapUp: active ? (_) {
setState(() => _pressing = false);
widget.onPressed!();
} : null,
onTapCancel: active ? () => setState(() => _pressing = false) : null,
child: AnimatedContainer(
duration: Duration(milliseconds: 80),
decoration: BoxDecoration(
color: bg,
borderRadius: radius,
border: Border.all(color: borderColor),
),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle.merge(
style: TextStyle(
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
),
child: IconTheme.merge(
data: IconThemeData(
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
),
child: widget.child,
),
),
),
),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import "package:flutter/widgets.dart" hide Tooltip;
import "package:shadcn_flutter/shadcn_flutter.dart" hide Row, Expanded;
import "../../providers/chat_provider.dart";
import "../../providers/cost_provider.dart";
import "../../providers/settings_provider.dart";
import "package:provider/provider.dart";
String _fmtTokens(int n) {
final s = n.toString();
final buf = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
buf.write(s[i]);
}
return buf.toString();
}
class FooterBar extends StatelessWidget {
const FooterBar({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mutedFg = theme.colorScheme.mutedForeground;
final borderColor = theme.colorScheme.border;
final bg = theme.colorScheme.muted.scaleAlpha(0.3);
final costProvider = context.watch<CostProvider>();
final settingsProvider = context.watch<SettingsProvider>();
final chatProvider = context.watch<ChatProvider>();
final model = settingsProvider.settings.model ?? "unknown";
final costUsd = costProvider.getTotalCostUsd();
final cost = "\$${costUsd.toStringAsFixed(4)}";
final inputToks = costProvider.getTotalInputTokens();
final outputToks = costProvider.getTotalOutputTokens();
final isLoading = chatProvider.isLoading;
final contextTokens = chatProvider.contextTokens;
final textStyle = TextStyle(
fontFamily: "monospace",
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: mutedFg,
);
Widget divider() => const Padding(
padding: EdgeInsets.symmetric(horizontal: 5),
child: SizedBox(height: 12, child: VerticalDivider(width: 1)),
);
Widget copyrightBlock() {
return Text(
"© 2026 IMBENJI.NET LTD - The Agency",
style: textStyle,
);
}
Widget statusBlock() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isLoading ? "running..." : "idle",
style: textStyle.copyWith(
color: isLoading
? theme.colorScheme.primary
: mutedFg,
),
),
divider(),
Text(model.split("/").last, style: textStyle),
],
);
}
Widget statsBlock() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contextTokens > 0) ...[
Text(_fmtTokens(contextTokens), style: textStyle),
Text(" tokens", style: textStyle),
divider(),
],
Tooltip(
tooltip: (_) => TooltipContainer(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Text(
"In: $inputToks\nOut: $outputToks",
style: const TextStyle(
fontFamily: "monospace",
fontSize: 11,
height: 1.2,
),
),
),
child: Text(cost, style: textStyle),
),
],
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
width: double.infinity,
decoration: BoxDecoration(
color: bg,
border: Border(top: BorderSide(color: borderColor, width: 1)),
),
child: Row(
children: [
Expanded(child: Row(children: [copyrightBlock()])),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [statusBlock()],
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [statsBlock()],
),
),
],
),
);
}
}
@@ -1,8 +1,8 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/settings_provider.dart";
import "model_picker.dart";
import "../../providers/settings_provider.dart";
import "../chat/model_picker.dart";
class SettingsSheet extends StatelessWidget {
const SettingsSheet();
+32
View File
@@ -0,0 +1,32 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class AppLogo extends StatelessWidget {
const AppLogo({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"THE AGENCY",
style: TextStyle(
fontSize: 32,
height: 1,
fontWeight: FontWeight.w900,
),
),
Text(
"by IMBENJI.NET LTD",
style: TextStyle(
fontSize: 12,
height: 1,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
],
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import '../../utils/format_relative_time.dart';
class ProjectButton extends StatefulWidget {
final String label;
final VoidCallback? onPressed;
final DateTime? lastMessage;
final bool collapsed;
ProjectButton({
required this.label,
this.onPressed,
this.lastMessage,
this.collapsed = false,
});
@override
State<ProjectButton> createState() => ProjectButtonState();
}
class ProjectButtonState extends State<ProjectButton> with TickerProviderStateMixin {
bool _isHovering = false;
late AnimationController _chevronController;
@override
void initState() {
super.initState();
_chevronController = AnimationController(
duration: Duration(milliseconds: 100),
vsync: this,
);
if (!widget.collapsed) {
_chevronController.forward();
}
}
@override
void didUpdateWidget(ProjectButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.collapsed != widget.collapsed) {
if (widget.collapsed) {
_chevronController.reverse();
} else {
_chevronController.forward();
}
}
}
@override
void dispose() {
_chevronController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
ColorScheme colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: double.infinity,
child: Button(
style: ButtonStyle.ghost().copyWith(
padding: (context, state, edgeInsets) {
return EdgeInsets.only(
top: 8,
left: 12,
bottom: 8,
right: 12
);
}
),
disableFocusOutline: true,
onPressed: () {
if (widget.onPressed != null) {
widget.onPressed!();
}
},
onHover: (isHovering) {
setState(() {
_isHovering = isHovering;
});
},
leading: !_isHovering ? Icon(
!widget.collapsed ? LucideIcons.folderOpen : LucideIcons.folderClosed
).iconSmall : RotationTransition(
turns: Tween(begin: 0.0, end: 0.25).animate(_chevronController),
child: Icon(
LucideIcons.chevronRight,
color: colorScheme.mutedForeground,
).iconSmall,
),
trailingGap: 32,
trailing: widget.lastMessage != null ?
Text(
formatRelativeTime(widget.lastMessage!)
).muted : null,
child: Text(
widget.label,
style: TextStyle(
color: colorScheme.mutedForeground
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small,
),
);
}
}
@@ -0,0 +1,98 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import 'project_button.dart';
class ProjectSection extends StatefulWidget {
final String projectLabel;
final List<Widget> children;
final VoidCallback? onHeaderPressed;
ProjectSection({
required this.projectLabel,
this.children = const [],
this.onHeaderPressed,
});
@override
State<ProjectSection> createState() => ProjectSectionState();
}
class ProjectSectionState extends State<ProjectSection> with TickerProviderStateMixin {
bool _isCollapsed = true;
late AnimationController _sizeController;
late AnimationController _fadeController;
@override
void initState() {
super.initState();
_sizeController = AnimationController(
duration: Duration(milliseconds: 150),
vsync: this,
);
_fadeController = AnimationController(
duration: Duration(milliseconds: 250),
vsync: this,
);
if (!_isCollapsed) {
_sizeController.forward();
_fadeController.forward();
}
}
@override
void dispose() {
_sizeController.dispose();
_fadeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
ProjectButton(
label: widget.projectLabel,
collapsed: _isCollapsed,
onPressed: () {
setState(() {
_isCollapsed = !_isCollapsed;
if (_isCollapsed) {
_fadeController.reverse();
_sizeController.reverse();
} else {
_fadeController.forward();
_sizeController.forward();
}
});
widget.onHeaderPressed?.call();
},
),
Gap(2),
ClipRect(
child: Align(
alignment: Alignment.topCenter,
child: FadeTransition(
opacity: _fadeController,
child: SizeTransition(
sizeFactor: _sizeController,
child: Column(
spacing: 2,
children: [
...widget.children
],
),
),
),
),
)
],
);
}
}
+175
View File
@@ -0,0 +1,175 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import '../../../src/session/session_types.dart';
import '../../providers/chat_provider.dart';
import '../../providers/home_coordinator.dart';
import '../../providers/projects_provider.dart';
import '../../providers/session_provider.dart';
import 'app_logo.dart';
import 'project_section.dart';
import 'thread_button.dart';
class Sidebar extends StatelessWidget {
const Sidebar({super.key});
@override
Widget build(BuildContext context) {
final projectsProvider = context.watch<ProjectsProvider>();
final sessionProvider = context.watch<SessionProvider>();
final chatProvider = context.watch<ChatProvider>();
final coordinator = context.read<HomeCoordinator>();
return Container(
width: 300,
color: Theme.of(context).colorScheme.input.scaleAlpha(0.3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 100,
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppLogo(),
),
Divider(),
Gap(16),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: _fixedSection(context, coordinator, chatProvider),
),
Gap(16),
Divider(),
Gap(16),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator),
),
),
],
),
);
}
Widget _fixedSection(BuildContext context, HomeCoordinator coordinator, ChatProvider chatProvider) {
return Column(
children: [
SizedBox(
width: double.infinity,
child: Button.ghost(
style: ButtonStyle.ghost().copyWith(
padding: (context, state, edgeInsets) {
return EdgeInsets.only(
top: 8,
left: 8,
bottom: 8,
right: 10
);
}
),
onPressed: chatProvider.isLoading ? null : coordinator.createNewChat,
disableFocusOutline: true,
leading: Icon(LucideIcons.squarePen).iconSmall,
alignment: Alignment.centerLeft,
child: Text("New Chat"),
),
),
SizedBox(
width: double.infinity,
child: Button.ghost(
style: ButtonStyle.ghost().copyWith(
padding: (context, state, edgeInsets) {
return EdgeInsets.only(
top: 8,
left: 8,
bottom: 8,
right: 10
);
}
),
onPressed: coordinator.pickProjectDirectory,
disableFocusOutline: true,
leading: Icon(LucideIcons.folderPlus).iconSmall,
alignment: Alignment.centerLeft,
child: Text("New Project"),
),
),
],
);
}
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator) {
if (projectsProvider.projects.isEmpty) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text("No projects yet").textSmall.muted,
);
}
// group sessions by working directory
final sessionsByProject = <String, List<SessionSummary>>{};
for (final session in sessionProvider.sessions) {
final dir = session.workingDirectory ?? '';
sessionsByProject.putIfAbsent(dir, () => []).add(session);
}
// sort sessions within each project newest first
final sorted = <String, List<SessionSummary>>{};
sessionsByProject.forEach((dir, sessions) {
sorted[dir] = List<SessionSummary>.from(sessions)
..sort((a, b) => b.updated.compareTo(a.updated));
});
return ListView(
padding: EdgeInsets.zero,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text("Projects").textMuted,
),
Gap(8),
for (final project in projectsProvider.projects) ...[
ProjectSection(
projectLabel: project.name,
children: [
if (sorted[project.workingDirectory]?.isEmpty ?? true)
ThreadButton(
label: "No threads yet",
muted: true,
)
else
for (final session in sorted[project.workingDirectory]!)
ThreadButton(
label: session.name,
lastMessage: session.updated,
selected: sessionProvider.currentSessionId == session.id,
onPressed: () => coordinator.openSession(session),
onDelete: () => coordinator.deleteSession(session),
),
],
),
Gap(2),
],
],
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import '../../utils/format_relative_time.dart';
class ThreadButton extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback? onPressed;
final VoidCallback? onDelete;
final DateTime? lastMessage;
final bool selected;
final bool muted;
ThreadButton({
required this.label,
this.icon,
this.onPressed,
this.onDelete,
this.lastMessage,
this.selected = false,
this.muted = false,
});
@override
Widget build(BuildContext context) {
ButtonStyle style = selected ? ButtonStyle.secondary() : ButtonStyle.ghost();
ColorScheme colorScheme = Theme.of(context).colorScheme;
final button = SizedBox(
width: double.infinity,
child: Button(
style: style.copyWith(
padding: (context, state, edgeInsets) {
return EdgeInsets.only(
top: 8,
left: 12,
bottom: 8,
right: 12
);
}
),
disableFocusOutline: true,
onPressed: onPressed ?? () {},
enabled: onPressed != null,
leading: Icon(
icon,
color: icon == null ? Colors.transparent : (muted ? colorScheme.mutedForeground : null),
).iconSmall,
trailingGap: 32,
trailing: lastMessage != null ?
Text(
formatRelativeTime(lastMessage!)
).muted.small.light : null,
child: Text(
label,
style: TextStyle(
color: (muted ? colorScheme.mutedForeground : null)
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small.light,
),
);
if (onDelete == null) return button;
return ContextMenu(
items: [
MenuButton(
onPressed: (_) => onDelete!(),
child: const Text("Delete"),
),
],
child: button,
);
}
}