Add command files and enhance session management features
This commit is contained in:
@@ -35,6 +35,7 @@ void main() async {
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => ChatProvider(
|
||||
context.read<SettingsProvider>(),
|
||||
context.read<CostProvider>(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
|
||||
@@ -71,6 +71,7 @@ class OpenRouterClient {
|
||||
double? temperature,
|
||||
List<Map<String, dynamic>>? tools,
|
||||
String? toolChoice,
|
||||
String? reasoning, // "low" | "medium" | "high" — maps to OpenRouter reasoning.effort
|
||||
}) async {
|
||||
final requestBody = <String, dynamic>{
|
||||
"model": model,
|
||||
@@ -99,6 +100,13 @@ class OpenRouterClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (reasoning != null) {
|
||||
// OpenRouter unified reasoning param — works across Anthropic, DeepSeek, Gemini etc
|
||||
// "max" is our internal alias; OpenRouter calls it "xhigh"
|
||||
final effort = reasoning == 'max' ? 'xhigh' : reasoning;
|
||||
requestBody["reasoning"] = <String, dynamic>{"effort": effort};
|
||||
}
|
||||
|
||||
final response = await _withRetry(
|
||||
() => _makeRequest(
|
||||
method: "POST",
|
||||
@@ -118,6 +126,7 @@ class OpenRouterClient {
|
||||
double? temperature,
|
||||
List<Map<String, dynamic>>? tools,
|
||||
String? toolChoice,
|
||||
String? reasoning, // "low" | "medium" | "high" — maps to OpenRouter reasoning.effort
|
||||
void Function(String delta)? onTextDelta,
|
||||
}) async {
|
||||
final requestBody = <String, dynamic>{
|
||||
@@ -148,6 +157,11 @@ class OpenRouterClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (reasoning != null) {
|
||||
final effort = reasoning == 'max' ? 'xhigh' : reasoning;
|
||||
requestBody["reasoning"] = <String, dynamic>{"effort": effort};
|
||||
}
|
||||
|
||||
final url = Uri.parse("$_baseUrl/chat/completions");
|
||||
final headers = _buildHeaders();
|
||||
|
||||
|
||||
+196
-3718
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,73 @@
|
||||
import "../api/api_types.dart";
|
||||
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.";
|
||||
|
||||
// Matches ADVISOR_TOOL_INSTRUCTIONS from old_repo/utils/advisor.ts
|
||||
// Verbatim from old_repo/utils/advisor.ts ADVISOR_TOOL_INSTRUCTIONS
|
||||
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.";
|
||||
"# Advisor Tool\n\n"
|
||||
"The advisor is a second-opinion and planning tool -- NOT an investigative tool. It takes NO parameters; your entire conversation history is forwarded automatically.\n\n"
|
||||
"Use it for:\n"
|
||||
"- Validating an implementation plan before you write code\n"
|
||||
"- Getting unstuck when errors recur or an approach isn't converging\n"
|
||||
"- A final review before declaring a multi-step task done\n\n"
|
||||
"Do NOT use it for:\n"
|
||||
"- Answering questions\n"
|
||||
"- Looking up information, searching the codebase, or reading files\n"
|
||||
"- Understanding what the project does or how something works\n"
|
||||
"- Anything you can just do yourself with a tool call\n\n"
|
||||
"The advisor cannot run tools. It only reads the conversation and gives you text guidance. If you need to know something, find it yourself first -- then call the advisor once you have a concrete plan to review.\n\n"
|
||||
"Give the advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim (the file says X, the code does Y), adapt. A passing self-test is not evidence the advice is wrong -- it's evidence your test doesn't check what the advice is checking.\n\n"
|
||||
"If you've already retrieved data pointing one way and the advisor points another: don't silently switch. Surface the conflict in one more advisor call -- \"I found X, you suggest Y, which constraint breaks the tie?\" The advisor saw your evidence but may have underweighted it; a reconcile call is cheaper than committing to the wrong branch.";
|
||||
|
||||
class AdvisorResult {
|
||||
const AdvisorResult({required this.text, required this.response});
|
||||
|
||||
final String text;
|
||||
|
||||
// null if the call failed
|
||||
final ApiMessage? response;
|
||||
}
|
||||
|
||||
class AdvisorService {
|
||||
|
||||
Future<String> run({
|
||||
Future<AdvisorResult> run({
|
||||
required String advisorModel,
|
||||
required String apiKey,
|
||||
required List<Map<String, dynamic>> conversationSoFar,
|
||||
required String systemPrompt,
|
||||
required List<Map<String, dynamic>> toolDefinitions,
|
||||
String? effortLevel,
|
||||
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 stripped = _stripDanglingToolUse(conversationSoFar);
|
||||
final transcript = _buildTranscript(stripped);
|
||||
|
||||
final response = await client.createMessage(
|
||||
model: advisorModel,
|
||||
maxTokens: 2048,
|
||||
messages: conversationSoFar,
|
||||
system: _advisorSystemPrompt,
|
||||
maxTokens: 8192,
|
||||
messages: [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Here is the conversation so far:\n\n$transcript\n\n"
|
||||
"You are acting as an advisor, not an executor. "
|
||||
"You MUST NOT call any tools or functions — tool calls are strictly forbidden. "
|
||||
"Give concise, actionable guidance in plain text only.",
|
||||
},
|
||||
],
|
||||
system: "$systemPrompt\n\n"
|
||||
"# Advisor Mode\n\n"
|
||||
"You are acting as an advisor reviewing the above conversation transcript. "
|
||||
"You MUST NOT call any tools or functions under any circumstances — not even once. "
|
||||
"Tool calls are strictly forbidden in this mode. "
|
||||
"Your response must be plain text only: analyze the conversation and give concise, actionable guidance.",
|
||||
reasoning: effortLevel,
|
||||
);
|
||||
|
||||
final text = response.content
|
||||
@@ -45,13 +79,89 @@ class AdvisorService {
|
||||
|
||||
final result = text.isEmpty ? "Advisor returned no guidance." : text;
|
||||
onToolResult?.call("Advisor", result);
|
||||
return result;
|
||||
return AdvisorResult(text: result, response: response);
|
||||
} catch (e) {
|
||||
final err = "Advisor call failed: $e";
|
||||
onToolResult?.call("Advisor", err);
|
||||
return err;
|
||||
return AdvisorResult(text: err, response: null);
|
||||
} finally {
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Converts conversation messages into a compact readable transcript.
|
||||
// Avoids raw JSON syntax overhead — roles become labels, tool calls/results
|
||||
// are shown as named blocks without all the JSON structure.
|
||||
String _buildTranscript(List<Map<String, dynamic>> messages) {
|
||||
final buf = StringBuffer();
|
||||
|
||||
for (final msg in messages) {
|
||||
final role = msg["role"] as String? ?? "unknown";
|
||||
|
||||
if (role == "user") {
|
||||
final content = msg["content"];
|
||||
buf.writeln("[user]");
|
||||
buf.writeln(content is String ? content : content.toString());
|
||||
buf.writeln();
|
||||
|
||||
} else if (role == "assistant") {
|
||||
final content = msg["content"];
|
||||
final toolCalls = msg["tool_calls"];
|
||||
|
||||
if (content is String && content.isNotEmpty) {
|
||||
buf.writeln("[assistant]");
|
||||
buf.writeln(content);
|
||||
buf.writeln();
|
||||
}
|
||||
|
||||
if (toolCalls is List) {
|
||||
for (final tc in toolCalls) {
|
||||
if (tc is! Map<String, dynamic>) continue;
|
||||
final fn = tc["function"] as Map<String, dynamic>?;
|
||||
final name = fn?["name"] ?? "tool";
|
||||
final args = fn?["arguments"] ?? "";
|
||||
buf.writeln("[tool call: $name]");
|
||||
buf.writeln(args);
|
||||
buf.writeln();
|
||||
}
|
||||
}
|
||||
|
||||
} else if (role == "tool") {
|
||||
final content = msg["content"];
|
||||
buf.writeln("[tool result]");
|
||||
buf.writeln(content is String ? content : content.toString());
|
||||
buf.writeln();
|
||||
}
|
||||
}
|
||||
|
||||
return buf.toString().trimRight();
|
||||
}
|
||||
|
||||
// The advisor is called mid-loop, so the last assistant message may contain
|
||||
// tool_use blocks whose tool_result hasn't been appended yet. Anthropic rejects
|
||||
// that. Strip any trailing assistant message that has unmatched tool_use calls.
|
||||
List<Map<String, dynamic>> _stripDanglingToolUse(
|
||||
List<Map<String, dynamic>> messages,
|
||||
) {
|
||||
if (messages.isEmpty) return messages;
|
||||
|
||||
final last = messages.last;
|
||||
if (last["role"] != "assistant") return messages;
|
||||
|
||||
// OpenAI format: tool calls are in message["tool_calls"]
|
||||
// Anthropic format: tool_use blocks inside message["content"] list
|
||||
final toolCalls = last["tool_calls"];
|
||||
final content = last["content"];
|
||||
|
||||
bool hasToolUse = (toolCalls is List && toolCalls.isNotEmpty) ||
|
||||
(content is List &&
|
||||
content.any(
|
||||
(b) => b is Map<String, dynamic> && b["type"] == "tool_use",
|
||||
));
|
||||
|
||||
if (!hasToolUse) return messages;
|
||||
|
||||
return messages.sublist(0, messages.length - 1);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,22 @@ 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";
|
||||
import "../tools/streaming_tool.dart";
|
||||
import "../tools/bash_tool.dart";
|
||||
|
||||
class AdvisorUsage {
|
||||
const AdvisorUsage({
|
||||
required this.model,
|
||||
required this.inputTokens,
|
||||
required this.outputTokens,
|
||||
required this.costUsd,
|
||||
});
|
||||
|
||||
final String model;
|
||||
final int inputTokens;
|
||||
final int outputTokens;
|
||||
final double costUsd;
|
||||
}
|
||||
|
||||
class ToolLoopResult {
|
||||
const ToolLoopResult({
|
||||
@@ -26,6 +42,7 @@ class ToolLoopResult {
|
||||
required this.finalResponseWasStreamed,
|
||||
required this.webSearchRequests,
|
||||
required this.webFetchRequests,
|
||||
this.advisorUsages = const [],
|
||||
});
|
||||
|
||||
final List<Map<String, dynamic>> apiMessages;
|
||||
@@ -34,6 +51,9 @@ class ToolLoopResult {
|
||||
final bool finalResponseWasStreamed;
|
||||
final int webSearchRequests;
|
||||
final int webFetchRequests;
|
||||
|
||||
// one entry per advisor call made this turn
|
||||
final List<AdvisorUsage> advisorUsages;
|
||||
}
|
||||
|
||||
class ToolLoopException implements Exception {
|
||||
@@ -75,6 +95,7 @@ class ToolLoopService {
|
||||
String? advisorModel,
|
||||
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
|
||||
void Function(String toolName, String result)? onToolResult,
|
||||
void Function(String toolName, String chunk)? onToolOutputChunk,
|
||||
void Function(String delta)? onAssistantTextDelta,
|
||||
void Function()? onAssistantMessageComplete,
|
||||
Future<PermissionDecision> Function(String toolName, Map<String, dynamic> input, {String? suggestionRule})? onPermissionRequired,
|
||||
@@ -115,19 +136,26 @@ class ToolLoopService {
|
||||
}
|
||||
|
||||
late ApiMessage lastResponse;
|
||||
final advisorUsages = <AdvisorUsage>[];
|
||||
|
||||
// build system prompt + tools once — reused each iteration and forwarded to advisor
|
||||
final systemPrompt = await _buildSystemPrompt(workingDirectory, model);
|
||||
final toolDefs = _buildToolDefinitions(advisorModel: advisorModel);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (shouldStop != null && shouldStop()) throw RequestCancelledException();
|
||||
|
||||
bool streamedTextThisIteration = false;
|
||||
final currentSettings = getSettings();
|
||||
lastResponse = await client.createStreamingMessage(
|
||||
model: model,
|
||||
maxTokens: 4096,
|
||||
maxTokens: 64000,
|
||||
messages: updatedMessages,
|
||||
system: await _buildSystemPrompt(workingDirectory, model),
|
||||
tools: _buildToolDefinitions(advisorModel: advisorModel),
|
||||
system: systemPrompt,
|
||||
tools: toolDefs,
|
||||
toolChoice: "auto",
|
||||
reasoning: currentSettings.effortLevel,
|
||||
onTextDelta: (delta) {
|
||||
streamedTextThisIteration = true;
|
||||
onAssistantTextDelta?.call(delta);
|
||||
@@ -153,6 +181,7 @@ class ToolLoopService {
|
||||
finalResponseWasStreamed: streamedTextThisIteration,
|
||||
webSearchRequests: lastResponse.webSearchRequests ?? 0,
|
||||
webFetchRequests: lastResponse.webFetchRequests ?? 0,
|
||||
advisorUsages: List.unmodifiable(advisorUsages),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,17 +190,52 @@ class ToolLoopService {
|
||||
|
||||
// advisor is handled separately — not via the tool registry
|
||||
if (toolUse.name == "Advisor") {
|
||||
onToolCall?.call("Advisor", {"model": advisorModel!});
|
||||
|
||||
if (onPermissionRequired != null) {
|
||||
final decision = await onPermissionRequired(
|
||||
"Advisor",
|
||||
{"model": advisorModel!},
|
||||
suggestionRule: "Advisor",
|
||||
);
|
||||
if (decision == PermissionDecision.reject) {
|
||||
const denied = "Advisor call declined by user.";
|
||||
onToolResult?.call("Advisor", denied);
|
||||
updatedMessages.add(<String, dynamic>{
|
||||
"role": "tool",
|
||||
"tool_call_id": toolUse.id,
|
||||
"content": denied,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
final advisorResult = await _advisorService.run(
|
||||
advisorModel: advisorModel!,
|
||||
apiKey: apiKey,
|
||||
conversationSoFar: List<Map<String, dynamic>>.from(updatedMessages),
|
||||
systemPrompt: systemPrompt,
|
||||
toolDefinitions: toolDefs,
|
||||
effortLevel: getSettings().advisorEffortLevel,
|
||||
onToolCall: onToolCall,
|
||||
onToolResult: onToolResult,
|
||||
);
|
||||
|
||||
if (advisorResult.response != null) {
|
||||
final r = advisorResult.response!;
|
||||
final rawUsage = r.usage;
|
||||
advisorUsages.add(AdvisorUsage(
|
||||
model: r.model,
|
||||
inputTokens: r.inputTokens ?? 0,
|
||||
outputTokens: r.outputTokens ?? 0,
|
||||
costUsd: (rawUsage?["cost"] as num?)?.toDouble() ?? 0.0,
|
||||
));
|
||||
}
|
||||
|
||||
updatedMessages.add(<String, dynamic>{
|
||||
"role": "tool",
|
||||
"tool_call_id": toolUse.id,
|
||||
"content": advisorResult,
|
||||
"content": advisorResult.text,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -228,12 +292,19 @@ class ToolLoopService {
|
||||
final toolResult = await _executeTool(
|
||||
toolUse: toolUse,
|
||||
normalizedInput: normalizedInput,
|
||||
onChunk: onToolOutputChunk != null
|
||||
? (chunk) => onToolOutputChunk(toolUse.name, chunk)
|
||||
: null,
|
||||
shouldStop: shouldStop,
|
||||
);
|
||||
onToolResult?.call(toolUse.name, toolResult);
|
||||
|
||||
// IMAGE_BLOCK results need structured content blocks, not plain text
|
||||
final toolResultContent = _buildToolResultContent(toolResult);
|
||||
updatedMessages.add(<String, dynamic>{
|
||||
"role": "tool",
|
||||
"tool_call_id": toolUse.id,
|
||||
"content": toolResult,
|
||||
"content": toolResultContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -255,6 +326,8 @@ class ToolLoopService {
|
||||
Future<String> _executeTool({
|
||||
required ToolUse toolUse,
|
||||
required Map<String, dynamic> normalizedInput,
|
||||
void Function(String chunk)? onChunk,
|
||||
bool Function()? shouldStop,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
print(
|
||||
@@ -262,7 +335,23 @@ class ToolLoopService {
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await _toolRegistry.execute(toolUse.name, normalizedInput);
|
||||
String result;
|
||||
final tool = _toolRegistry.getTool(toolUse.name);
|
||||
|
||||
if (tool is BashTool) {
|
||||
tool.shouldStop = shouldStop;
|
||||
}
|
||||
|
||||
if (onChunk != null && tool is StreamingTool) {
|
||||
result = await (tool as StreamingTool).executeStreaming(
|
||||
normalizedInput,
|
||||
onChunk: onChunk,
|
||||
);
|
||||
} else {
|
||||
result = await _toolRegistry.execute(toolUse.name, normalizedInput);
|
||||
}
|
||||
|
||||
if (tool is BashTool) tool.shouldStop = null;
|
||||
final success = !result.startsWith("Error");
|
||||
await _toolTelemetryClient.recordToolCall(
|
||||
toolName: toolUse.name,
|
||||
@@ -466,9 +555,9 @@ class ToolLoopService {
|
||||
_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.",
|
||||
"A second-opinion and planning tool — NOT an investigative tool. "
|
||||
"Call when you need to validate an implementation approach, get a plan reviewed, or break out of a stuck state. "
|
||||
"Do NOT call to answer questions, look things up, search the codebase, or understand the project — just do those yourself.",
|
||||
properties: <String, dynamic>{},
|
||||
required: const <String>[],
|
||||
),
|
||||
@@ -708,4 +797,48 @@ class ToolLoopService {
|
||||
|
||||
return "The model completed the turn without returning visible text.";
|
||||
}
|
||||
|
||||
// Converts a tool result string to the appropriate API content format.
|
||||
// IMAGE_BLOCK:<mediaType>:<base64> → image block list
|
||||
// anything else → plain string
|
||||
dynamic _buildToolResultContent(String result) {
|
||||
const prefix = "IMAGE_BLOCK:";
|
||||
if (!result.startsWith(prefix)) return result;
|
||||
|
||||
// may contain multiple IMAGE_BLOCK lines (e.g. notebook with output images)
|
||||
final lines = result.split("\n");
|
||||
final blocks = <Map<String, dynamic>>[];
|
||||
final textBuf = StringBuffer();
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.startsWith(prefix)) {
|
||||
if (textBuf.isNotEmpty) {
|
||||
blocks.add({"type": "text", "text": textBuf.toString().trim()});
|
||||
textBuf.clear();
|
||||
}
|
||||
final parts = line.substring(prefix.length).split(":");
|
||||
if (parts.length >= 2) {
|
||||
final mediaType = parts[0];
|
||||
final b64 = parts.sublist(1).join(":");
|
||||
blocks.add({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:$mediaType;base64,$b64"},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (textBuf.isNotEmpty) textBuf.write("\n");
|
||||
textBuf.write(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (textBuf.isNotEmpty) {
|
||||
blocks.add({"type": "text", "text": textBuf.toString().trim()});
|
||||
}
|
||||
|
||||
if (blocks.isEmpty) return result;
|
||||
if (blocks.length == 1 && blocks[0]["type"] == "image_url") {
|
||||
return blocks;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '../local_state.dart';
|
||||
import '../session/conversation_history.dart';
|
||||
import '../local_state.dart' show joinPath;
|
||||
import '../utils/uuid_utils.dart';
|
||||
|
||||
// shared singleton history for the current run
|
||||
final history = ConversationHistory();
|
||||
|
||||
String makeSessionId() => generateUuid();
|
||||
|
||||
const commonHelpArgs = <String>['help', '-h', '--help'];
|
||||
const commonInfoArgs = <String>['current', 'info', 'show', 'status'];
|
||||
|
||||
const defaultStatuslinePrompt =
|
||||
'Configure my statusLine from my shell PS1 configuration';
|
||||
|
||||
const max20xTier = 'default_claude_max_20x';
|
||||
|
||||
const modelAliases = <String>[
|
||||
'best',
|
||||
'haiku',
|
||||
'opus',
|
||||
'opus[1m]',
|
||||
'opusplan',
|
||||
'sonnet',
|
||||
'sonnet[1m]',
|
||||
];
|
||||
|
||||
String formatDuration(Duration duration) {
|
||||
String twoDigits(int value) => value.toString().padLeft(2, '0');
|
||||
final hours = twoDigits(duration.inHours);
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$hours:$minutes:$seconds';
|
||||
}
|
||||
|
||||
String renderModelSetting(String rawModel) {
|
||||
switch (rawModel.toLowerCase()) {
|
||||
case 'best':
|
||||
return 'Best available';
|
||||
case 'haiku':
|
||||
return 'Claude Haiku';
|
||||
case 'opus':
|
||||
return 'Claude Opus';
|
||||
case 'opus[1m]':
|
||||
return 'Claude Opus [1m]';
|
||||
case 'opusplan':
|
||||
return 'Opus plan mode';
|
||||
case 'sonnet':
|
||||
return 'Claude Sonnet';
|
||||
case 'sonnet[1m]':
|
||||
return 'Claude Sonnet [1m]';
|
||||
default:
|
||||
return rawModel;
|
||||
}
|
||||
}
|
||||
|
||||
String resolveCurrentModelSetting(CommandContext context) {
|
||||
final configured = context.settingsStore.settings.model;
|
||||
if (configured != null && configured.trim().isNotEmpty) {
|
||||
return configured.trim();
|
||||
}
|
||||
return "anthropic/claude-3.5-sonnet";
|
||||
}
|
||||
|
||||
String showCurrentEffort(CommandContext context) {
|
||||
final envLevel = getEffortEnvLevelOverride();
|
||||
final effectiveValue = isEffortEnvClearOverride()
|
||||
? null
|
||||
: envLevel ?? context.sessionState.effortValue;
|
||||
|
||||
if (effectiveValue == null || effectiveValue.isEmpty) {
|
||||
return 'Effort level: auto (currently high)';
|
||||
}
|
||||
|
||||
return 'Current effort level: $effectiveValue (${getEffortDescription(effectiveValue)})';
|
||||
}
|
||||
|
||||
String getEffortDescription(String effort) {
|
||||
switch (effort) {
|
||||
case 'low':
|
||||
return 'Quick, straightforward implementation with minimal overhead';
|
||||
case 'medium':
|
||||
return 'Balanced approach with standard implementation and testing';
|
||||
case 'high':
|
||||
return 'Comprehensive implementation with extensive testing and documentation';
|
||||
case 'max':
|
||||
return 'Maximum capability with deepest reasoning (Opus 4.6 only)';
|
||||
default:
|
||||
return 'Balanced approach with standard implementation and testing';
|
||||
}
|
||||
}
|
||||
|
||||
String? getApplicableEffortEnvRaw() {
|
||||
final raw = Platform.environment['CLAUDE_CODE_EFFORT_LEVEL'];
|
||||
if (raw == null || raw.trim().isEmpty) return null;
|
||||
|
||||
final normalized = raw.trim().toLowerCase();
|
||||
if (isEffortEnvClearOverride() || supportedEffortLevels.contains(normalized)) {
|
||||
return raw.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? getEffortEnvLevelOverride() {
|
||||
final raw = getApplicableEffortEnvRaw();
|
||||
if (raw == null) return null;
|
||||
|
||||
final normalized = raw.toLowerCase();
|
||||
if (supportedEffortLevels.contains(normalized)) return normalized;
|
||||
return null;
|
||||
}
|
||||
|
||||
bool isEffortEnvClearOverride() {
|
||||
final raw = Platform.environment['CLAUDE_CODE_EFFORT_LEVEL'];
|
||||
if (raw == null || raw.trim().isEmpty) return false;
|
||||
final normalized = raw.trim().toLowerCase();
|
||||
return normalized == 'auto' || normalized == 'unset';
|
||||
}
|
||||
|
||||
String buildStatuslineAgentInstruction(String prompt) =>
|
||||
'Create an Agent with subagent_type "statusline-setup" and the prompt "$prompt"';
|
||||
|
||||
String homeDir() =>
|
||||
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
|
||||
|
||||
String theAgencyHome() => joinPath(homeDir(), '.the_agency');
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '../local_state.dart' show joinPath;
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final dirArg = args.join(" ").trim();
|
||||
|
||||
if (dirArg.isEmpty || commonHelpArgs.contains(dirArg.toLowerCase())) {
|
||||
context.writeLine(
|
||||
'Usage: /add-dir <path>\n\n'
|
||||
'Add a directory to the current session workspace.\n'
|
||||
'Claude will be able to read and edit files in the added directory.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final resolved = dirArg.startsWith('/')
|
||||
? dirArg
|
||||
: joinPath(context.workingDirectory, dirArg);
|
||||
|
||||
final dir = Directory(resolved);
|
||||
if (!await dir.exists()) {
|
||||
context.writeError('Directory does not exist: $resolved');
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
if (context.sessionState.additionalDirectories.contains(resolved)) {
|
||||
context.writeLine('Directory already in workspace: $resolved');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.sessionState.additionalDirectories.add(resolved);
|
||||
context.writeLine('Added directory to workspace: $resolved');
|
||||
context.writeLine('Active workspace directories:');
|
||||
context.writeLine(' ${context.workingDirectory} (primary)');
|
||||
for (final d in context.sessionState.additionalDirectories) {
|
||||
context.writeLine(' $d');
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final arg = args.join(' ').trim().toLowerCase();
|
||||
|
||||
if (arg.isEmpty) {
|
||||
final current = context.sessionState.advisorModel
|
||||
?? context.settingsStore.settings.advisorModel;
|
||||
if (current == null) {
|
||||
context.writeLine(
|
||||
'Advisor: not set\nUse "/advisor <model>" to enable (e.g. "/advisor opus").',
|
||||
);
|
||||
} else {
|
||||
context.writeLine(
|
||||
'Advisor: $current\nUse "/advisor unset" to disable or "/advisor <model>" to change.',
|
||||
);
|
||||
}
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (arg == 'unset' || arg == 'off') {
|
||||
final prev = context.sessionState.advisorModel
|
||||
?? context.settingsStore.settings.advisorModel;
|
||||
context.sessionState.advisorModel = null;
|
||||
await context.settingsStore.update((s) => s.copyWith(advisorModel: null));
|
||||
context.writeLine(prev != null ? 'Advisor disabled (was $prev).' : 'Advisor already unset.');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (commonHelpArgs.contains(arg)) {
|
||||
context.writeLine('Usage: /advisor [<model>|off]\n\nSet the advisor model for the session.');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.sessionState.advisorModel = arg;
|
||||
await context.settingsStore.update((s) => s.copyWith(advisorModel: arg));
|
||||
context.writeLine('Advisor set to $arg.');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Agents");
|
||||
context.writeLine("");
|
||||
context.writeLine("The interactive agents manager is not ported to the Dart CLI.");
|
||||
context.writeLine(
|
||||
"In the legacy CLI this shows a menu to configure which tools agents can use.",
|
||||
);
|
||||
context.writeLine("");
|
||||
context.writeLine("Available agent tools are determined by your permission settings.");
|
||||
context.writeLine("Use /permissions to manage tool access rules.");
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '../daemon/daemon_manager.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
if (args.isEmpty) {
|
||||
context.writeLine("Usage: /attach <session-id>");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
final id = args[0];
|
||||
final mgr = DaemonManager();
|
||||
|
||||
final rec = await mgr.loadRecord(id);
|
||||
if (rec == null) {
|
||||
context.writeLine("Session not found: $id");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
final desc = await mgr.describeSession(id);
|
||||
if (desc != null) {
|
||||
context.writeLine(desc);
|
||||
context.writeLine("--- streaming logs (Ctrl-C to stop) ---");
|
||||
context.writeLine("");
|
||||
}
|
||||
|
||||
await for (final chunk in mgr.streamLogs(id)) {
|
||||
stdout.write(chunk);
|
||||
}
|
||||
|
||||
return const CommandResult(exitCode: 0);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import '../command.dart';
|
||||
import '../session/session_store.dart';
|
||||
import '../session/session_types.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final customTitle = args.join(" ").trim();
|
||||
|
||||
if (!history.hasSession) {
|
||||
context.writeLine("No active session to branch from.");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
final src = history.session!;
|
||||
final now = DateTime.now().toUtc();
|
||||
final newId = makeSessionId();
|
||||
final branchName = customTitle.isNotEmpty ? customTitle : "${src.name} (branch)";
|
||||
|
||||
final forked = ConversationSession(
|
||||
id: newId,
|
||||
name: branchName,
|
||||
created: now,
|
||||
updated: now,
|
||||
messages: src.messages.map((m) => Message(
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
tokens: m.tokens,
|
||||
)).toList(),
|
||||
model: src.model,
|
||||
);
|
||||
|
||||
await SessionStore.instance.saveSession(forked);
|
||||
history.setSession(forked);
|
||||
context.sessionState.sessionName = branchName;
|
||||
|
||||
context.writeLine('Branched into new session: "$branchName"');
|
||||
context.writeLine("New session ID: $newId");
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final newState = !context.sessionState.briefModeEnabled;
|
||||
context.sessionState.briefModeEnabled = newState;
|
||||
context.writeLine(newState ? 'Brief-only mode enabled' : 'Brief-only mode disabled');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final question = args.join(" ").trim();
|
||||
|
||||
context.writeLine("Side Question (btw)");
|
||||
context.writeLine("");
|
||||
|
||||
if (question.isEmpty) {
|
||||
context.writeLine("Usage: /btw <question>");
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"Ask a quick side question without affecting the main conversation context.",
|
||||
);
|
||||
} else {
|
||||
context.writeLine("Question: $question");
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"Side question mode is not fully ported - this requires a live model session.",
|
||||
);
|
||||
context.writeLine(
|
||||
"The question would normally be answered without adding to the main context.",
|
||||
);
|
||||
}
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArg = args.join(' ').trim().toLowerCase();
|
||||
|
||||
if (rawArg == 'status' || rawArg == 'current') {
|
||||
context.writeLine(
|
||||
context.sessionState.bughunterMode ? 'Bug hunter mode: ON' : 'Bug hunter mode: OFF',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final newState = !context.sessionState.bughunterMode;
|
||||
context.sessionState.bughunterMode = newState;
|
||||
context.writeLine(newState ? 'Bug hunter mode: ON' : 'Bug hunter mode: OFF');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
const extensionUrl = 'https://claude.ai/chrome';
|
||||
const permissionsUrl = 'https://clau.de/chrome/permissions';
|
||||
|
||||
context.writeLine('Claude in Chrome (Beta)');
|
||||
context.writeLine('');
|
||||
context.writeLine('Lets Claude access your browser context when you\'re on claude.ai.');
|
||||
context.writeLine('');
|
||||
context.writeLine('Extension: $extensionUrl');
|
||||
context.writeLine('Permissions: $permissionsUrl');
|
||||
context.writeLine('');
|
||||
context.writeLine('The interactive Chrome extension settings panel is not ported to the Dart runtime.');
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.out.write('\x1B[2J\x1B[H');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '../command.dart';
|
||||
import '../local_state.dart';
|
||||
|
||||
const _resetAliases = <String>['default', 'reset', 'none', 'gray', 'grey'];
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim().toLowerCase();
|
||||
if (rawArgs.isEmpty) {
|
||||
final colorList = supportedAgentColors.join(', ');
|
||||
context.writeLine('Please provide a color. Available colors: $colorList, default');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (_resetAliases.contains(rawArgs)) {
|
||||
context.sessionState.sessionColor = null;
|
||||
context.writeLine('Session color reset to default');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (!supportedAgentColors.contains(rawArgs)) {
|
||||
final colorList = supportedAgentColors.join(', ');
|
||||
context.writeLine('Invalid color "$rawArgs". Available colors: $colorList, default');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.sessionState.sessionColor = rawArgs;
|
||||
context.writeLine('Session color set to: $rawArgs');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine('Create git commit');
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'This is a prompt-type command. In the legacy CLI it sends the current git diff'
|
||||
' to the model and asks it to stage and create a commit.',
|
||||
);
|
||||
context.writeLine('');
|
||||
|
||||
try {
|
||||
final statusResult = await Process.run(
|
||||
'git',
|
||||
['status', '--short'],
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
final statusOut = (statusResult.stdout as String).trim();
|
||||
if (statusOut.isEmpty) {
|
||||
context.writeLine('git status: nothing to commit, working tree clean');
|
||||
} else {
|
||||
context.writeLine('Current changes:');
|
||||
context.writeLine(statusOut);
|
||||
}
|
||||
} on ProcessException {
|
||||
context.writeLine('(could not run git status)');
|
||||
}
|
||||
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'Run `git add` and `git commit` manually, or use the legacy CLI for AI-assisted commits.',
|
||||
);
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Commit, push, and open a PR");
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"This is a prompt-type command. In the legacy CLI it uses the AI model to:\n"
|
||||
" 1. Create a branch (if on main)\n"
|
||||
" 2. Stage and commit all changes\n"
|
||||
" 3. Push to origin\n"
|
||||
" 4. Create or update a GitHub PR via gh",
|
||||
);
|
||||
context.writeLine("");
|
||||
|
||||
try {
|
||||
final branchResult = await Process.run(
|
||||
"git", ["branch", "--show-current"],
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
final branch = (branchResult.stdout as String).trim();
|
||||
if (branch.isNotEmpty) context.writeLine("Current branch: $branch");
|
||||
|
||||
final statusResult = await Process.run(
|
||||
"git", ["status", "--short"],
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
final status = (statusResult.stdout as String).trim();
|
||||
if (status.isEmpty) {
|
||||
context.writeLine("Nothing to commit (working tree clean).");
|
||||
} else {
|
||||
context.writeLine("Uncommitted changes:");
|
||||
context.writeLine(status);
|
||||
}
|
||||
} on ProcessException {
|
||||
context.writeLine("(could not run git commands)");
|
||||
}
|
||||
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"Run `git commit && git push && gh pr create` manually, or use the legacy CLI for AI-assisted PR creation.",
|
||||
);
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final instructions = args.join(' ').trim();
|
||||
context.writeLine(
|
||||
'Compact conversation: message history is not available in the Dart CLI runtime yet.',
|
||||
);
|
||||
if (instructions.isNotEmpty) {
|
||||
context.writeLine('Custom instructions provided: "$instructions"');
|
||||
}
|
||||
context.writeLine(
|
||||
'\nIn the legacy CLI this summarizes all messages and replaces them with a summary,'
|
||||
' keeping the context window fresh.',
|
||||
);
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
const _jsonEncoder = JsonEncoder.withIndent(' ');
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim().toLowerCase();
|
||||
if (rawArgs == 'path' || rawArgs == 'open') {
|
||||
context.writeLine(context.settingsStore.path);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (rawArgs.isNotEmpty && rawArgs != 'show') {
|
||||
context.writeLine('Usage: /config [show|path]');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
context.writeLine('Config file: ${context.settingsStore.path}');
|
||||
context.writeLine('Runtime state: ${context.runtimeStateStore.path}');
|
||||
context.writeLine('');
|
||||
context.writeLine('Settings:');
|
||||
context.writeLine(_jsonEncoder.convert(context.settingsStore.settings.toJson()));
|
||||
context.writeLine('');
|
||||
context.writeLine('Runtime state:');
|
||||
context.writeLine(_jsonEncoder.convert(context.runtimeStateStore.state.toJson()));
|
||||
context.writeLine('');
|
||||
context.writeLine('Session state:');
|
||||
context.writeLine(' planModeEnabled: ${context.sessionState.planModeEnabled}');
|
||||
context.writeLine(' sessionColor: ${context.sessionState.sessionColor ?? 'default'}');
|
||||
context.writeLine(' effortValue: ${context.sessionState.effortValue ?? 'auto'}');
|
||||
context.writeLine(' planFilePath: ${context.sessionState.planFilePath}');
|
||||
context.writeLine(' commandsExecuted: ${context.sessionState.commandsExecuted}');
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final elapsed = DateTime.now().toUtc().difference(context.sessionState.startedAt);
|
||||
|
||||
context.writeLine('Context window usage');
|
||||
context.writeLine('');
|
||||
context.writeLine(' Token accounting is not ported to the Dart runtime yet.');
|
||||
context.writeLine(' In the legacy CLI this shows a colored grid of used vs available context.');
|
||||
context.writeLine('');
|
||||
context.writeLine(' Session uptime: ${formatDuration(elapsed)}');
|
||||
context.writeLine(' Commands run: ${context.sessionState.commandsExecuted}');
|
||||
context.writeLine(' Working dir: ${context.workingDirectory}');
|
||||
if (context.sessionState.additionalDirectories.isNotEmpty) {
|
||||
context.writeLine(' Extra dirs: ${context.sessionState.additionalDirectories.length}');
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
if (!history.hasSession) {
|
||||
context.writeLine("No active session - nothing to copy.");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
final msgs = history.getMessages();
|
||||
final assistantMsgs = msgs.where((m) => m.role == "assistant").toList();
|
||||
|
||||
if (assistantMsgs.isEmpty) {
|
||||
context.writeLine("No assistant messages in the current session.");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
int idx = assistantMsgs.length - 1;
|
||||
if (args.isNotEmpty) {
|
||||
final parsed = int.tryParse(args.first.trim());
|
||||
if (parsed != null && parsed > 0 && parsed <= assistantMsgs.length) {
|
||||
idx = assistantMsgs.length - parsed;
|
||||
}
|
||||
}
|
||||
|
||||
final msg = assistantMsgs[idx];
|
||||
context.writeLine(msg.content);
|
||||
context.writeLine(
|
||||
"\n(Note: clipboard copy via OSC 52 is not wired in the Dart runtime - text printed above)",
|
||||
);
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import '../command.dart';
|
||||
import '../services/cost_tracker.dart' as costTracker;
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine(costTracker.formatTotalCost());
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
if (!Platform.isMacOS && !Platform.isWindows) {
|
||||
context.writeLine('Claude Desktop is only available on macOS and Windows.');
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
context.writeLine('Claude Desktop');
|
||||
context.writeLine('');
|
||||
context.writeLine('Opens the current session in the Claude Desktop app.');
|
||||
context.writeLine('');
|
||||
context.writeLine('Session handoff to Claude Desktop is not ported to the Dart CLI runtime.');
|
||||
context.writeLine('Download Claude Desktop: https://claude.ai/download');
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(" ").trim();
|
||||
|
||||
try {
|
||||
final result = await Process.run(
|
||||
'git',
|
||||
rawArgs.isEmpty ? ['diff'] : ['diff', ...args],
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
|
||||
if (result.exitCode != 0 && (result.stderr as String).isNotEmpty) {
|
||||
context.writeError((result.stderr as String).trim());
|
||||
return CommandResult(exitCode: result.exitCode);
|
||||
}
|
||||
|
||||
final out = (result.stdout as String).trim();
|
||||
if (out.isEmpty) {
|
||||
context.writeLine("No changes (clean working tree)");
|
||||
} else {
|
||||
context.writeLine(out);
|
||||
}
|
||||
} on ProcessException catch (e) {
|
||||
context.writeError("Could not run git diff: ${e.message}");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '../local_state.dart' show joinPath;
|
||||
|
||||
const _largeClaudeMdWarningChars = 40000;
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final workingDirectory = Directory(context.workingDirectory);
|
||||
final legacyRoot = Directory(joinPath(context.workingDirectory, 'old_repo'));
|
||||
final claudeMdFile = File(
|
||||
joinPath(joinPath(context.workingDirectory, '.the_agency'), 'THE_AGENCY.md'),
|
||||
);
|
||||
final hasGit = await Directory(joinPath(context.workingDirectory, '.git')).exists();
|
||||
final hasLegacyPackageManifest =
|
||||
await File(joinPath(legacyRoot.path, 'package.json')).exists() ||
|
||||
await File(joinPath(legacyRoot.path, 'tsconfig.json')).exists();
|
||||
final configFile = File(context.settingsStore.path);
|
||||
final runtimeFile = File(context.runtimeStateStore.path);
|
||||
|
||||
context.writeLine('Doctor');
|
||||
context.writeLine(
|
||||
'Runtime: Dart ${Platform.version.split(' ').first} on ${Platform.operatingSystem}',
|
||||
);
|
||||
context.writeLine('Working directory: ${workingDirectory.path}');
|
||||
context.writeLine('');
|
||||
|
||||
context.writeLine(
|
||||
'[ok] settings: ${await configFile.exists() ? context.settingsStore.path : 'missing'}',
|
||||
);
|
||||
context.writeLine(
|
||||
'[ok] runtime state: ${await runtimeFile.exists() ? context.runtimeStateStore.path : 'missing'}',
|
||||
);
|
||||
context.writeLine(
|
||||
'[${await legacyRoot.exists() ? 'ok' : 'warn'}] legacy source root: ${legacyRoot.path}',
|
||||
);
|
||||
context.writeLine(
|
||||
'[${hasGit ? 'ok' : 'warn'}] git repository: ${hasGit ? 'detected' : 'not detected'}',
|
||||
);
|
||||
|
||||
if (await claudeMdFile.exists()) {
|
||||
final length = await claudeMdFile.length();
|
||||
final level = length > _largeClaudeMdWarningChars ? 'warn' : 'ok';
|
||||
context.writeLine(
|
||||
'[$level] THE_AGENCY.md: ${claudeMdFile.path} (${length.toString()} bytes)',
|
||||
);
|
||||
} else {
|
||||
context.writeLine('[warn] THE_AGENCY.md: not found');
|
||||
}
|
||||
|
||||
context.writeLine(
|
||||
'[${hasLegacyPackageManifest ? 'ok' : 'warn'}] legacy manifests: ${hasLegacyPackageManifest ? 'detected' : 'old_repo has no package.json or tsconfig.json at its root'}',
|
||||
);
|
||||
context.writeLine('');
|
||||
context.writeLine('Notes:');
|
||||
if (!hasGit) {
|
||||
context.writeLine(' - This workspace is not currently inside a git repository.');
|
||||
}
|
||||
if (!hasLegacyPackageManifest) {
|
||||
context.writeLine(
|
||||
' - Exact legacy runtime reproduction is harder because old_repo lacks a checked-in package manifest.',
|
||||
);
|
||||
}
|
||||
if (!await legacyRoot.exists()) {
|
||||
context.writeLine(' - old_repo is missing, so legacy source parity checks cannot run.');
|
||||
}
|
||||
if (hasGit && hasLegacyPackageManifest && await legacyRoot.exists()) {
|
||||
context.writeLine(' - No obvious environment blockers detected.');
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import '../command.dart';
|
||||
import '../local_state.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
const _helpText =
|
||||
'Usage: /effort [low|medium|high|max|auto]\n\n'
|
||||
'Effort levels:\n'
|
||||
'- low: Quick, straightforward implementation\n'
|
||||
'- medium: Balanced approach with standard testing\n'
|
||||
'- high: Comprehensive implementation with extensive testing\n'
|
||||
'- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n'
|
||||
'- auto: Use the default effort level for your model';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim();
|
||||
if (commonHelpArgs.contains(rawArgs)) {
|
||||
context.writeLine(_helpText);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (rawArgs.isEmpty || rawArgs == 'current' || rawArgs == 'status') {
|
||||
context.writeLine(showCurrentEffort(context));
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final normalized = rawArgs.toLowerCase();
|
||||
if (normalized == 'auto' || normalized == 'unset') {
|
||||
context.sessionState.effortValue = null;
|
||||
await context.settingsStore.update(
|
||||
(settings) => settings.copyWith(effortLevel: null),
|
||||
);
|
||||
|
||||
final applicableEnvRaw = getApplicableEffortEnvRaw();
|
||||
if (applicableEnvRaw != null && !isEffortEnvClearOverride()) {
|
||||
context.writeLine(
|
||||
'Cleared effort from settings, but CLAUDE_CODE_EFFORT_LEVEL=$applicableEnvRaw still controls this session',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine('Effort level set to auto');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (!supportedEffortLevels.contains(normalized)) {
|
||||
context.writeLine(
|
||||
'Invalid argument: $rawArgs. Valid options are: low, medium, high, max, auto',
|
||||
);
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
context.sessionState.effortValue = normalized;
|
||||
if (normalized == 'max') {
|
||||
final applicableEnvRaw = getApplicableEffortEnvRaw();
|
||||
if (applicableEnvRaw != null &&
|
||||
getEffortEnvLevelOverride() != normalized) {
|
||||
context.writeLine(
|
||||
'Not applied: CLAUDE_CODE_EFFORT_LEVEL=$applicableEnvRaw overrides effort this session, and $normalized is session-only (nothing saved)',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine(
|
||||
'Set effort level to $normalized (this session only): ${getEffortDescription(normalized)}',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
await context.settingsStore.update(
|
||||
(settings) => settings.copyWith(effortLevel: normalized),
|
||||
);
|
||||
final applicableEnvRaw = getApplicableEffortEnvRaw();
|
||||
if (applicableEnvRaw != null && getEffortEnvLevelOverride() != normalized) {
|
||||
context.writeLine(
|
||||
'CLAUDE_CODE_EFFORT_LEVEL=$applicableEnvRaw overrides this session — clear it and $normalized takes over',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine(
|
||||
'Set effort level to $normalized: ${getEffortDescription(normalized)}',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
const _relevantKeys = <String>[
|
||||
'OPENROUTER_API_KEY',
|
||||
'CLAUDE_CODE_EFFORT_LEVEL',
|
||||
'CLAUDE_CODE_SKIP_PERMISSIONS_CHECK',
|
||||
'EDITOR',
|
||||
'VISUAL',
|
||||
'HOME',
|
||||
'PATH',
|
||||
'SHELL',
|
||||
'USER',
|
||||
'USER_TYPE',
|
||||
];
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Environment variables:");
|
||||
for (final key in _relevantKeys) {
|
||||
final val = Platform.environment[key];
|
||||
if (val != null) {
|
||||
final display = key.contains('KEY') && val.length > 8
|
||||
? '${val.substring(0, 4)}...${val.substring(val.length - 4)}'
|
||||
: val;
|
||||
context.writeLine(" $key=$display");
|
||||
} else {
|
||||
context.writeLine(" $key=(unset)");
|
||||
}
|
||||
}
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
return const CommandResult(exitRepl: true);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final filename = args.join(" ").trim();
|
||||
|
||||
if (!history.hasSession) {
|
||||
context.writeLine("No active session to export.");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
final sess = history.session!;
|
||||
final isJson = filename.endsWith(".json");
|
||||
final content = isJson ? history.exportToJson() : history.exportToText();
|
||||
|
||||
if (filename.isEmpty) {
|
||||
context.writeLine(content);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
try {
|
||||
final file = File(filename);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(content);
|
||||
context.writeLine('Exported ${sess.messageCount} messages to: $filename');
|
||||
} catch (e) {
|
||||
context.writeError("Failed to write export file: $e");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim().toLowerCase();
|
||||
if (commonHelpArgs.contains(rawArgs)) {
|
||||
context.writeLine('Usage: /fast [on|off|status]');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (rawArgs.isEmpty || rawArgs == 'status' || rawArgs == 'current') {
|
||||
context.writeLine(
|
||||
context.settingsStore.settings.fastMode ? 'Fast mode ON' : 'Fast mode OFF',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (rawArgs != 'on' && rawArgs != 'off') {
|
||||
context.writeLine(
|
||||
'Invalid argument: $rawArgs. Valid options are: on, off, status',
|
||||
);
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
final enabled = rawArgs == 'on';
|
||||
await context.settingsStore.update(
|
||||
(settings) => settings.copyWith(fastMode: enabled),
|
||||
);
|
||||
context.writeLine(enabled ? 'Fast mode ON' : 'Fast mode OFF');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final report = args.join(' ').trim();
|
||||
const feedbackUrl = 'https://github.com/anthropics/claude-code/issues/new';
|
||||
|
||||
context.writeLine('Submit Feedback / Bug Report');
|
||||
context.writeLine('');
|
||||
|
||||
if (report.isNotEmpty) {
|
||||
context.writeLine('Your report: "$report"');
|
||||
context.writeLine('');
|
||||
}
|
||||
|
||||
context.writeLine('Interactive feedback submission is not ported to the Dart CLI runtime yet.');
|
||||
context.writeLine('Please open an issue at: $feedbackUrl');
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("No files in context");
|
||||
context.writeLine(
|
||||
"(Note: file context tracking is not yet ported to the Dart CLI runtime)",
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final requestedCommand = args.isEmpty
|
||||
? null
|
||||
: args.first.startsWith('/')
|
||||
? args.first.substring(1)
|
||||
: args.first;
|
||||
|
||||
if (requestedCommand != null) {
|
||||
final ported = context.catalog.findPorted(requestedCommand, InvocationSurface.both);
|
||||
final legacy = context.catalog.findLegacy(requestedCommand, InvocationSurface.both);
|
||||
final reserved = context.catalog.findReservedTopLevel(requestedCommand);
|
||||
final descriptor = ported ?? legacy ?? reserved;
|
||||
|
||||
if (descriptor == null) {
|
||||
context.writeError('No known command named "$requestedCommand".');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
_writeCommandDetails(context, descriptor);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine('Usage:');
|
||||
context.writeLine(' clawd_code Start the interactive CLI');
|
||||
context.writeLine(' clawd_code --help Show help');
|
||||
context.writeLine(' clawd_code --version Print version');
|
||||
context.writeLine(' clawd_code <entrypoint> Run a known top-level legacy entrypoint');
|
||||
context.writeLine('');
|
||||
context.writeLine('Ported commands:');
|
||||
for (final command in context.catalog.portedCommands) {
|
||||
final aliases = command.aliases.isEmpty
|
||||
? ''
|
||||
: ' (aliases: ${command.aliases.join(', ')})';
|
||||
context.writeLine(' /${command.name}$aliases');
|
||||
}
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'Known legacy slash commands: ${context.catalog.totalKnownSlashCommands}',
|
||||
);
|
||||
context.writeLine(
|
||||
'Reserved top-level legacy entrypoints: ${context.catalog.totalReservedTopLevelEntryPoints}',
|
||||
);
|
||||
context.writeLine(
|
||||
'Remaining unported slash commands: ${context.catalog.unportedSlashCommands.length}',
|
||||
);
|
||||
context.writeLine('');
|
||||
context.writeLine('Examples:');
|
||||
context.writeLine(' /status');
|
||||
context.writeLine(' /model opus');
|
||||
context.writeLine(' /permissions allow Bash(npm test)');
|
||||
context.writeLine(' /init preview');
|
||||
context.writeLine(' remote-control');
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
void _writeCommandDetails(CommandContext context, LegacyCommandDescriptor descriptor) {
|
||||
context.writeLine('Command: ${descriptor.name}');
|
||||
context.writeLine('Surface: ${descriptor.surface.label}');
|
||||
context.writeLine('Kind: ${descriptor.kind.name}');
|
||||
if (descriptor.aliases.isNotEmpty) {
|
||||
context.writeLine('Aliases: ${descriptor.aliases.join(', ')}');
|
||||
}
|
||||
if (descriptor.description != null && descriptor.description!.isNotEmpty) {
|
||||
context.writeLine('Description: ${descriptor.description!}');
|
||||
}
|
||||
context.writeLine('Legacy source: ${descriptor.legacySourcePath}');
|
||||
if (descriptor.isInferred) {
|
||||
context.writeLine('Metadata note: name inferred from legacy file path.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim().toLowerCase();
|
||||
|
||||
if (commonHelpArgs.contains(rawArgs)) {
|
||||
context.writeLine(
|
||||
'Usage: /hooks\n\n'
|
||||
'View and manage hook configurations for tool events.\n'
|
||||
'Hooks are defined in your settings file:\n'
|
||||
' ${context.settingsStore.path}',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final hooks = context.settingsStore.settings.hooks;
|
||||
|
||||
context.writeLine('Hook configurations');
|
||||
context.writeLine('Settings file: ${context.settingsStore.path}');
|
||||
context.writeLine('');
|
||||
|
||||
if (hooks == null || hooks.isEmpty) {
|
||||
context.writeLine('No hooks configured.');
|
||||
context.writeLine('');
|
||||
context.writeLine('Hooks allow you to run scripts when tools are used.');
|
||||
context.writeLine('Add them to your settings.json under the "hooks" key.');
|
||||
} else {
|
||||
context.writeLine('Configured hooks:');
|
||||
for (final entry in hooks.entries) {
|
||||
context.writeLine(' ${entry.key}: ${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final termProgram = Platform.environment['TERM_PROGRAM'] ?? '';
|
||||
final askpassMain = Platform.environment['VSCODE_GIT_ASKPASS_MAIN'] ?? '';
|
||||
final path = Platform.environment['PATH'] ?? '';
|
||||
|
||||
String? detectedIde;
|
||||
|
||||
if (termProgram == 'vscode') detectedIde = 'VSCode';
|
||||
else if (termProgram == 'cursor') detectedIde = 'Cursor';
|
||||
else if (termProgram == 'windsurf') detectedIde = 'Windsurf';
|
||||
else if (askpassMain.contains('cursor-server') || path.contains('cursor-server')) detectedIde = 'Cursor (remote)';
|
||||
else if (askpassMain.contains('windsurf-server') || path.contains('windsurf-server')) detectedIde = 'Windsurf (remote)';
|
||||
else if (askpassMain.contains('vscode-server') || path.contains('vscode-server')) detectedIde = 'VSCode (remote)';
|
||||
|
||||
context.writeLine('IDE Integration');
|
||||
context.writeLine('');
|
||||
|
||||
if (detectedIde != null) {
|
||||
context.writeLine('Detected IDE: $detectedIde');
|
||||
} else {
|
||||
context.writeLine('No supported IDE detected from environment.');
|
||||
}
|
||||
|
||||
context.writeLine('');
|
||||
context.writeLine('Supported integrations: VSCode, Cursor, Windsurf (via the Claude extension)');
|
||||
context.writeLine('');
|
||||
context.writeLine('The interactive IDE management panel is not ported to the Dart CLI runtime.');
|
||||
context.writeLine('Install the Claude extension from the marketplace in your IDE.');
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '../local_state.dart' show joinPath;
|
||||
|
||||
const _initHeader =
|
||||
'# THE_AGENCY.md\n\n'
|
||||
'This file provides guidance to The Agency when working with code in this repository.\n';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final command = args.isEmpty ? 'write' : args.first.toLowerCase();
|
||||
final force = args.any((arg) => arg == '--force' || arg == 'force');
|
||||
final agencyDir = joinPath(context.workingDirectory, '.the_agency');
|
||||
final theAgencyMdPath = joinPath(agencyDir, 'THE_AGENCY.md');
|
||||
final targetFile = File(theAgencyMdPath);
|
||||
final draft = await _buildDraft(context.workingDirectory);
|
||||
|
||||
if (command == 'preview' || command == 'show') {
|
||||
context.writeLine(draft);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (!force && await targetFile.exists()) {
|
||||
context.writeLine('THE_AGENCY.md already exists at $theAgencyMdPath');
|
||||
context.writeLine(
|
||||
'Run /init preview to inspect the regenerated draft or /init force to overwrite it.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
await Directory(agencyDir).create(recursive: true);
|
||||
await targetFile.writeAsString('$draft\n');
|
||||
context.writeLine('Wrote $theAgencyMdPath');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
Future<String> _buildDraft(String workingDirectory) async {
|
||||
final commands = await _collectDetectedCommands(workingDirectory);
|
||||
final architecture = await _collectArchitectureNotes(workingDirectory);
|
||||
final buffer = StringBuffer()..write(_initHeader);
|
||||
|
||||
if (commands.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
buffer.writeln('## Common Commands');
|
||||
for (final command in commands) {
|
||||
buffer.writeln('- `$command`');
|
||||
}
|
||||
}
|
||||
|
||||
if (architecture.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
buffer.writeln('## Architecture');
|
||||
for (final note in architecture) {
|
||||
buffer.writeln('- $note');
|
||||
}
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
buffer.writeln('## Notes');
|
||||
buffer.writeln(
|
||||
'- Preserve the Dart CLI surface while using `old_repo/` as the legacy behavior reference during migration work.',
|
||||
);
|
||||
buffer.writeln(
|
||||
'- Prefer concise, targeted changes over broad rewrites unless a command or runtime subsystem is being ported intentionally.',
|
||||
);
|
||||
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
|
||||
Future<List<String>> _collectArchitectureNotes(String workingDirectory) async {
|
||||
final notes = <String>[];
|
||||
final binDir = Directory(joinPath(workingDirectory, 'bin'));
|
||||
final libDir = Directory(joinPath(workingDirectory, 'lib'));
|
||||
final oldRepoDir = Directory(joinPath(workingDirectory, 'old_repo'));
|
||||
final testDir = Directory(joinPath(workingDirectory, 'test'));
|
||||
|
||||
if (await binDir.exists()) {
|
||||
notes.add('`bin/` contains the executable entrypoints for the Dart CLI.');
|
||||
}
|
||||
if (await libDir.exists()) {
|
||||
notes.add('`lib/src/` contains the migrated Dart command/runtime implementation.');
|
||||
}
|
||||
if (await oldRepoDir.exists()) {
|
||||
notes.add('`old_repo/` is the legacy TypeScript reference implementation being ported 1:1.');
|
||||
}
|
||||
if (await testDir.exists()) {
|
||||
notes.add('`test/` holds Dart validation coverage for the migrated runtime.');
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
Future<List<String>> _collectDetectedCommands(String workingDirectory) async {
|
||||
final commands = <String>[];
|
||||
final pubspecFile = File(joinPath(workingDirectory, 'pubspec.yaml'));
|
||||
final packageJsonFile = File(joinPath(workingDirectory, 'package.json'));
|
||||
final cargoFile = File(joinPath(workingDirectory, 'Cargo.toml'));
|
||||
final goModFile = File(joinPath(workingDirectory, 'go.mod'));
|
||||
final makeFile = File(joinPath(workingDirectory, 'Makefile'));
|
||||
final pomFile = File(joinPath(workingDirectory, 'pom.xml'));
|
||||
final testDir = Directory(joinPath(workingDirectory, 'test'));
|
||||
final binDir = Directory(joinPath(workingDirectory, 'bin'));
|
||||
|
||||
if (await pubspecFile.exists()) {
|
||||
commands.add('dart pub get');
|
||||
commands.add('dart analyze');
|
||||
if (await testDir.exists()) {
|
||||
commands.add('dart test');
|
||||
}
|
||||
if (await binDir.exists()) {
|
||||
final binEntries = await binDir
|
||||
.list()
|
||||
.where((entity) => entity is File)
|
||||
.cast<File>()
|
||||
.toList();
|
||||
if (binEntries.isNotEmpty) {
|
||||
final firstFile = binEntries.first.uri.pathSegments.last;
|
||||
commands.add('dart run bin/$firstFile');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (await packageJsonFile.exists()) {
|
||||
commands.addAll(await _extractPackageJsonCommands(packageJsonFile));
|
||||
}
|
||||
if (await cargoFile.exists()) commands.addAll(['cargo build', 'cargo test']);
|
||||
if (await goModFile.exists()) commands.add('go test ./...');
|
||||
if (await pomFile.exists()) commands.add('mvn test');
|
||||
if (await makeFile.exists()) commands.add('make');
|
||||
|
||||
return commands.toSet().toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<String>> _extractPackageJsonCommands(File packageJsonFile) async {
|
||||
try {
|
||||
final raw = await packageJsonFile.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) return const [];
|
||||
|
||||
final scripts = decoded['scripts'];
|
||||
if (scripts is! Map) return const [];
|
||||
|
||||
final commands = <String>[];
|
||||
for (final entry in scripts.entries) {
|
||||
final key = entry.key.toString();
|
||||
if (key == 'build' || key == 'lint' || key == 'test' || key == 'dev') {
|
||||
commands.add('npm run $key');
|
||||
}
|
||||
}
|
||||
return commands;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Init verifiers");
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"This command analyzes your project and creates verifier skills in .claude/skills/.\n"
|
||||
"Verifier skills are used by the Verify agent to automatically verify code changes.",
|
||||
);
|
||||
context.writeLine("");
|
||||
context.writeLine("Supported verifier types:");
|
||||
context.writeLine(" verifier-playwright - for web UIs (Playwright)");
|
||||
context.writeLine(" verifier-cli - for CLI tools (Tmux)");
|
||||
context.writeLine(" verifier-api - for HTTP API services");
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"In the legacy CLI this runs an AI prompt that detects your project type\n"
|
||||
"and generates the skill file interactively. Use the legacy CLI for full support.",
|
||||
);
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
const docsUrl = 'https://docs.anthropic.com/en/docs/claude-code/github-actions';
|
||||
|
||||
context.writeLine('Install GitHub App');
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'Sets up Claude GitHub Actions for a repository so Claude can review PRs\n'
|
||||
'and respond to issues automatically.',
|
||||
);
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'The interactive setup wizard (OAuth, repo selection, workflow creation)\n'
|
||||
'is not ported to the Dart CLI runtime yet.',
|
||||
);
|
||||
context.writeLine('');
|
||||
context.writeLine('Documentation: $docsUrl');
|
||||
context.writeLine('');
|
||||
|
||||
try {
|
||||
final ghResult = await Process.run(
|
||||
'gh',
|
||||
['repo', 'view', '--json', 'name,owner', '--jq', '.owner.login + "/" + .name'],
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
final repo = (ghResult.stdout as String).trim();
|
||||
if (repo.isNotEmpty) {
|
||||
context.writeLine('Current repo: $repo');
|
||||
context.writeLine('Run: gh workflow list (to see existing workflows)');
|
||||
}
|
||||
} on ProcessException {
|
||||
// gh not installed, thats fine
|
||||
}
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '../local_state.dart' show joinPath;
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final keybindingsPath = joinPath(joinPath(homeDir(), '.claude'), 'keybindings.json');
|
||||
|
||||
final rawArgs = args.join(" ").trim().toLowerCase();
|
||||
if (commonHelpArgs.contains(rawArgs)) {
|
||||
context.writeLine(
|
||||
'Usage: /keybindings\n\n'
|
||||
'Opens your keybindings config file in \$EDITOR or \$VISUAL.\n'
|
||||
'File location: $keybindingsPath',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final f = File(keybindingsPath);
|
||||
final existed = await f.exists();
|
||||
|
||||
if (!existed) {
|
||||
await Directory(joinPath(homeDir(), '.claude')).create(recursive: true);
|
||||
await f.writeAsString(
|
||||
'// Claude Code keybindings\n'
|
||||
'// See docs for available actions.\n'
|
||||
'[\n'
|
||||
' // { "key": "ctrl+shift+r", "action": "clearHistory" }\n'
|
||||
']\n',
|
||||
);
|
||||
}
|
||||
|
||||
final editor = Platform.environment['VISUAL'] ?? Platform.environment['EDITOR'];
|
||||
|
||||
if (editor == null) {
|
||||
context.writeLine(
|
||||
'${existed ? 'Keybindings file' : 'Created keybindings file'}: $keybindingsPath',
|
||||
);
|
||||
context.writeLine('Set \$EDITOR or \$VISUAL to open it automatically.');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
try {
|
||||
final proc = await Process.start(editor, [keybindingsPath], mode: ProcessStartMode.inheritStdio);
|
||||
await proc.exitCode;
|
||||
context.writeLine('${existed ? 'Opened' : 'Created and opened'} $keybindingsPath');
|
||||
} on ProcessException catch (e) {
|
||||
context.writeError('Could not open editor ($editor): ${e.message}');
|
||||
context.writeLine('File is at: $keybindingsPath');
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../command.dart';
|
||||
import '../daemon/daemon_manager.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
if (args.isEmpty) {
|
||||
context.writeLine("Usage: /kill <session-id> [--force]");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
final id = args[0];
|
||||
final force = args.contains("--force") || args.contains("-f");
|
||||
|
||||
final mgr = DaemonManager();
|
||||
final ok = await mgr.killSession(id, force: force);
|
||||
|
||||
if (ok) {
|
||||
context.writeLine("Killed session: $id");
|
||||
return const CommandResult(exitCode: 0);
|
||||
}
|
||||
|
||||
final rec = await mgr.loadRecord(id);
|
||||
if (rec == null) {
|
||||
context.writeLine("Session not found: $id");
|
||||
} else {
|
||||
context.writeLine("Could not kill session $id (status=${rec.status.name})");
|
||||
}
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '../local_state.dart' show joinPath;
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim();
|
||||
|
||||
if (commonHelpArgs.contains(rawArgs.toLowerCase())) {
|
||||
context.writeLine('Usage: /lint\n\nRun the project linter.');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final pubspecFile = File(joinPath(context.workingDirectory, 'pubspec.yaml'));
|
||||
final packageJsonFile = File(joinPath(context.workingDirectory, 'package.json'));
|
||||
|
||||
List<String> lintCmd;
|
||||
String label;
|
||||
|
||||
if (await pubspecFile.exists()) {
|
||||
lintCmd = ['dart', 'analyze'];
|
||||
label = 'dart analyze';
|
||||
} else if (await packageJsonFile.exists()) {
|
||||
lintCmd = ['npm', 'run', 'lint'];
|
||||
label = 'npm run lint';
|
||||
} else {
|
||||
context.writeLine('Could not detect project type. No pubspec.yaml or package.json found.');
|
||||
context.writeLine('Run your linter manually.');
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
context.writeLine('Running: $label');
|
||||
context.writeLine('');
|
||||
|
||||
try {
|
||||
final result = await Process.run(
|
||||
lintCmd.first,
|
||||
lintCmd.sublist(1),
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
|
||||
final out = (result.stdout as String).trim();
|
||||
final err = (result.stderr as String).trim();
|
||||
|
||||
if (out.isNotEmpty) context.writeLine(out);
|
||||
if (err.isNotEmpty) context.writeError(err);
|
||||
|
||||
if (result.exitCode == 0) {
|
||||
context.writeLine('');
|
||||
context.writeLine('No issues found.');
|
||||
}
|
||||
|
||||
return CommandResult(exitCode: result.exitCode);
|
||||
} on ProcessException catch (e) {
|
||||
context.writeError('Could not run $label: ${e.message}');
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine('OpenRouter API key configuration has moved to settings.');
|
||||
context.writeLine(
|
||||
'Set your API key in the Settings panel to authenticate with OpenRouter.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine('To remove your OpenRouter API key, clear it in Settings.');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '../command.dart';
|
||||
import '../daemon/daemon_manager.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
if (args.isEmpty) {
|
||||
context.writeLine("Usage: /logs <session-id> [--tail N]");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
final id = args[0];
|
||||
int? tail;
|
||||
|
||||
for (var i = 1; i < args.length - 1; i++) {
|
||||
if (args[i] == "--tail" || args[i] == "-n") {
|
||||
tail = int.tryParse(args[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
final mgr = DaemonManager();
|
||||
final contents = await mgr.readLogs(id, tail: tail);
|
||||
|
||||
if (contents == null) {
|
||||
context.writeLine("No logs found for session: $id");
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
context.writeLine(contents);
|
||||
return const CommandResult(exitCode: 0);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim();
|
||||
final parts = rawArgs.split(RegExp(r'\s+'));
|
||||
final sub = parts.isNotEmpty ? parts.first.toLowerCase() : '';
|
||||
|
||||
if (sub == 'help' || rawArgs == '--help' || rawArgs == '-h') {
|
||||
context.writeLine(
|
||||
'Usage: /mcp [list|add <name> <command> [args...]|remove <name>|enable <name>|disable <name>]\n\n'
|
||||
'Manage MCP (Model Context Protocol) servers.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final servers = Map<String, Map<String, dynamic>>.from(
|
||||
context.settingsStore.settings.mcpServers ?? {},
|
||||
);
|
||||
|
||||
if (sub.isEmpty || sub == 'list') {
|
||||
context.writeLine('MCP servers');
|
||||
context.writeLine('Settings file: ${context.settingsStore.path}');
|
||||
context.writeLine('');
|
||||
|
||||
if (servers.isEmpty) {
|
||||
context.writeLine('No MCP servers configured.');
|
||||
context.writeLine('');
|
||||
context.writeLine('Use /mcp add <name> <command> to add a server.');
|
||||
} else {
|
||||
for (final entry in servers.entries) {
|
||||
final cfg = entry.value;
|
||||
final cmd = cfg['command'] ?? '(no command)';
|
||||
final disabled = cfg['disabled'] == true;
|
||||
context.writeLine(' ${entry.key}: $cmd${disabled ? ' [disabled]' : ''}');
|
||||
}
|
||||
}
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (sub == 'add') {
|
||||
if (parts.length < 3) {
|
||||
context.writeLine('Usage: /mcp add <name> <command> [args...]');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
final name = parts[1];
|
||||
final command = parts[2];
|
||||
final cmdArgs = parts.length > 3 ? parts.sublist(3) : <String>[];
|
||||
|
||||
servers[name] = <String, dynamic>{
|
||||
'command': command,
|
||||
if (cmdArgs.isNotEmpty) 'args': cmdArgs,
|
||||
};
|
||||
|
||||
await context.settingsStore.update((s) => s.copyWith(mcpServers: servers));
|
||||
context.writeLine('Added MCP server "$name" ($command)');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (sub == 'remove') {
|
||||
if (parts.length < 2) {
|
||||
context.writeLine('Usage: /mcp remove <name>');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
final name = parts[1];
|
||||
if (!servers.containsKey(name)) {
|
||||
context.writeLine('MCP server "$name" not found.');
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
servers.remove(name);
|
||||
await context.settingsStore.update(
|
||||
(s) => s.copyWith(mcpServers: servers.isEmpty ? null : servers),
|
||||
);
|
||||
context.writeLine('Removed MCP server "$name"');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (sub == 'enable' || sub == 'disable') {
|
||||
final isEnable = sub == 'enable';
|
||||
final target = parts.length > 1 ? parts.sublist(1).join(' ') : 'all';
|
||||
|
||||
if (target == 'all') {
|
||||
for (final name in servers.keys) {
|
||||
servers[name] = Map<String, dynamic>.from(servers[name]!)..remove('disabled');
|
||||
if (!isEnable) servers[name]!['disabled'] = true;
|
||||
}
|
||||
await context.settingsStore.update((s) => s.copyWith(mcpServers: servers));
|
||||
context.writeLine(
|
||||
'${isEnable ? 'Enabled' : 'Disabled'} ${servers.length} MCP server(s)',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (!servers.containsKey(target)) {
|
||||
context.writeLine('MCP server "$target" not found.');
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
servers[target] = Map<String, dynamic>.from(servers[target]!)..remove('disabled');
|
||||
if (!isEnable) servers[target]!['disabled'] = true;
|
||||
|
||||
await context.settingsStore.update((s) => s.copyWith(mcpServers: servers));
|
||||
context.writeLine('MCP server "$target" ${isEnable ? 'enabled' : 'disabled'}');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine('Unknown subcommand "$sub". Run /mcp help for usage.');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
import '../local_state.dart' show joinPath;
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final theAgencyHomeDir = theAgencyHome();
|
||||
final globalMemoryPath = joinPath(theAgencyHomeDir, 'THE_AGENCY.md');
|
||||
final localMemoryPath = joinPath(
|
||||
joinPath(context.workingDirectory, '.the_agency'),
|
||||
'THE_AGENCY.md',
|
||||
);
|
||||
|
||||
final rawArgs = args.join(" ").trim().toLowerCase();
|
||||
|
||||
if (commonHelpArgs.contains(rawArgs)) {
|
||||
context.writeLine(
|
||||
'Usage: /memory [global|local]\n\n'
|
||||
'Edit memory files.\n\n'
|
||||
'Files:\n'
|
||||
' global $globalMemoryPath\n'
|
||||
' local $localMemoryPath\n\n'
|
||||
'Without an argument, lists the available memory files.',
|
||||
);
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (rawArgs == 'global') {
|
||||
context.writeLine("Global memory file: $globalMemoryPath");
|
||||
final f = File(globalMemoryPath);
|
||||
if (await f.exists()) {
|
||||
final len = await f.length();
|
||||
context.writeLine(" Size: $len bytes");
|
||||
} else {
|
||||
context.writeLine(" (does not exist yet)");
|
||||
}
|
||||
context.writeLine("\nTo edit, open the file in your editor:\n \$EDITOR $globalMemoryPath");
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (rawArgs == 'local' || rawArgs.isEmpty) {
|
||||
context.writeLine("Local memory file: $localMemoryPath");
|
||||
final f = File(localMemoryPath);
|
||||
if (await f.exists()) {
|
||||
final len = await f.length();
|
||||
context.writeLine(" Size: $len bytes");
|
||||
} else {
|
||||
context.writeLine(" (does not exist yet)");
|
||||
}
|
||||
context.writeLine("Global memory file: $globalMemoryPath");
|
||||
context.writeLine(
|
||||
"\nTo edit, open a file in your editor:\n \$EDITOR $localMemoryPath",
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine("Usage: /memory [global|local]");
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
const iosUrl = 'https://apps.apple.com/app/claude-by-anthropic/id6473753684';
|
||||
const androidUrl = 'https://play.google.com/store/apps/details?id=com.anthropic.claude';
|
||||
|
||||
final arg = args.join(' ').trim().toLowerCase();
|
||||
|
||||
final isAndroid = arg == 'android';
|
||||
final url = isAndroid ? androidUrl : iosUrl;
|
||||
final platform = isAndroid ? 'Android' : 'iOS';
|
||||
|
||||
context.writeLine('Download Claude on $platform');
|
||||
context.writeLine('');
|
||||
context.writeLine(' iOS: $iosUrl');
|
||||
context.writeLine(' Android: $androidUrl');
|
||||
context.writeLine('');
|
||||
context.writeLine('QR code rendering is not ported to the Dart CLI runtime.');
|
||||
context.writeLine('Open this link on your phone: $url');
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim();
|
||||
if (commonHelpArgs.contains(rawArgs.toLowerCase())) {
|
||||
context.writeLine('Usage: /model [default|current|status|<model>]');
|
||||
context.writeLine('Known aliases: ${modelAliases.join(', ')}');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (rawArgs.isEmpty || commonInfoArgs.contains(rawArgs.toLowerCase())) {
|
||||
final current = resolveCurrentModelSetting(context);
|
||||
context.writeLine(
|
||||
'Current model: ${renderModelSetting(current)}${context.settingsStore.settings.model == null ? ' (default)' : ''}',
|
||||
);
|
||||
if (context.settingsStore.settings.model != null) {
|
||||
context.writeLine('Saved model override: ${context.settingsStore.settings.model}');
|
||||
}
|
||||
if (context.settingsStore.settings.fastMode) {
|
||||
context.writeLine('Fast mode: ON');
|
||||
}
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final normalized = rawArgs.toLowerCase();
|
||||
if (normalized == 'default' || normalized == 'auto' || normalized == 'unset') {
|
||||
await context.settingsStore.update((settings) => settings.copyWith(model: null));
|
||||
context.writeLine(
|
||||
'Set model to ${renderModelSetting(resolveCurrentModelSetting(context))}',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final requestedModel = _normalizeModelInput(rawArgs);
|
||||
var message = 'Set model to ${renderModelSetting(requestedModel)}';
|
||||
final fastSupported = _supportsFastMode(requestedModel);
|
||||
|
||||
if (!fastSupported && context.settingsStore.settings.fastMode) {
|
||||
await context.settingsStore.update(
|
||||
(settings) => settings.copyWith(model: requestedModel, fastMode: false),
|
||||
);
|
||||
message += ' · Fast mode OFF';
|
||||
context.writeLine(message);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
await context.settingsStore.update((settings) => settings.copyWith(model: requestedModel));
|
||||
if (context.settingsStore.settings.fastMode) {
|
||||
message += ' · Fast mode ON';
|
||||
}
|
||||
context.writeLine(message);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
String _normalizeModelInput(String rawModel) {
|
||||
final trimmed = rawModel.trim();
|
||||
final lowered = trimmed.toLowerCase();
|
||||
if (modelAliases.contains(lowered)) return lowered;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
bool _supportsFastMode(String model) {
|
||||
final normalized = model.toLowerCase();
|
||||
return normalized.contains('opus') || normalized.contains('sonnet');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine(
|
||||
'/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import '../command.dart';
|
||||
import '../local_state.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
if (args.isEmpty) {
|
||||
_writeSummary(context);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final subcommand = args.first.toLowerCase();
|
||||
if (commonHelpArgs.contains(subcommand)) {
|
||||
context.writeLine(
|
||||
'Usage: /permissions [show|mode <mode>|allow <rule>|deny <rule>|ask <rule>|remove <index|rule>|clear [allow|deny|ask|all]]',
|
||||
);
|
||||
context.writeLine('Modes: ${supportedPermissionModes.join(', ')}');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (subcommand == 'show' ||
|
||||
subcommand == 'list' ||
|
||||
commonInfoArgs.contains(subcommand)) {
|
||||
_writeSummary(context);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (subcommand == 'mode') {
|
||||
if (args.length == 1) {
|
||||
context.writeLine(
|
||||
'Current permission mode: ${context.settingsStore.settings.permissionMode}',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final requestedMode = args[1];
|
||||
if (!supportedPermissionModes.contains(requestedMode)) {
|
||||
context.writeLine(
|
||||
'Invalid permission mode "$requestedMode". Valid options: ${supportedPermissionModes.join(', ')}',
|
||||
);
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
await context.settingsStore.update(
|
||||
(settings) => settings.copyWith(permissionMode: requestedMode),
|
||||
);
|
||||
context.writeLine('Permission mode set to $requestedMode');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (subcommand == 'allow' || subcommand == 'deny' || subcommand == 'ask') {
|
||||
final rule = args.skip(1).join(' ').trim();
|
||||
if (rule.isEmpty) {
|
||||
context.writeLine('Please provide a permission rule.');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
await context.settingsStore.update(
|
||||
(settings) => _applyRule(settings, subcommand, rule),
|
||||
);
|
||||
context.writeLine('Added $subcommand rule: $rule');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (subcommand == 'clear') {
|
||||
final target = args.length > 1 ? args[1].toLowerCase() : 'all';
|
||||
if (!<String>['all', 'allow', 'deny', 'ask'].contains(target)) {
|
||||
context.writeLine('Usage: /permissions clear [allow|deny|ask|all]');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
await context.settingsStore.update((settings) => _clearRules(settings, target));
|
||||
context.writeLine(
|
||||
target == 'all' ? 'Cleared all permission rules' : 'Cleared $target rules',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (subcommand == 'remove') {
|
||||
final target = args.skip(1).join(' ').trim();
|
||||
if (target.isEmpty) {
|
||||
context.writeLine('Usage: /permissions remove <index|rule>');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
final removal = _removeRule(context.settingsStore.settings, target);
|
||||
if (!removal.removed) {
|
||||
context.writeLine('No permission rule matched "$target".');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
await context.settingsStore.update((settings) => removal.settings);
|
||||
context.writeLine('Removed permission rule: ${removal.label}');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine('Unknown /permissions subcommand "$subcommand".');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
void _writeSummary(CommandContext context) {
|
||||
final settings = context.settingsStore.settings;
|
||||
final flattened = _flatten(settings);
|
||||
context.writeLine('Permission mode: ${settings.permissionMode}');
|
||||
if (flattened.isEmpty) {
|
||||
context.writeLine('No permission rules configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
context.writeLine('Permission rules:');
|
||||
for (var i = 0; i < flattened.length; i++) {
|
||||
final entry = flattened[i];
|
||||
context.writeLine(' ${i + 1}. ${entry.behavior}: ${entry.rule}');
|
||||
}
|
||||
}
|
||||
|
||||
LocalSettings _applyRule(LocalSettings settings, String behavior, String rule) {
|
||||
final allowRules = settings.alwaysAllowRules.where((item) => item != rule).toList();
|
||||
final denyRules = settings.alwaysDenyRules.where((item) => item != rule).toList();
|
||||
final askRules = settings.alwaysAskRules.where((item) => item != rule).toList();
|
||||
|
||||
switch (behavior) {
|
||||
case 'allow':
|
||||
allowRules.add(rule);
|
||||
break;
|
||||
case 'deny':
|
||||
denyRules.add(rule);
|
||||
break;
|
||||
case 'ask':
|
||||
askRules.add(rule);
|
||||
break;
|
||||
}
|
||||
|
||||
return settings.copyWith(
|
||||
alwaysAllowRules: allowRules,
|
||||
alwaysAskRules: askRules,
|
||||
alwaysDenyRules: denyRules,
|
||||
);
|
||||
}
|
||||
|
||||
LocalSettings _clearRules(LocalSettings settings, String target) {
|
||||
switch (target) {
|
||||
case 'allow':
|
||||
return settings.copyWith(alwaysAllowRules: const []);
|
||||
case 'deny':
|
||||
return settings.copyWith(alwaysDenyRules: const []);
|
||||
case 'ask':
|
||||
return settings.copyWith(alwaysAskRules: const []);
|
||||
case 'all':
|
||||
return settings.copyWith(
|
||||
alwaysAllowRules: const [],
|
||||
alwaysAskRules: const [],
|
||||
alwaysDenyRules: const [],
|
||||
);
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
_RemovalResult _removeRule(LocalSettings settings, String target) {
|
||||
final flattened = _flatten(settings);
|
||||
final index = int.tryParse(target);
|
||||
if (index != null) {
|
||||
final entryIndex = index - 1;
|
||||
if (entryIndex < 0 || entryIndex >= flattened.length) {
|
||||
return _RemovalResult(removed: false, settings: settings, label: target);
|
||||
}
|
||||
final entry = flattened[entryIndex];
|
||||
return _RemovalResult(
|
||||
removed: true,
|
||||
settings: _removeByLabel(settings, entry.behavior, entry.rule),
|
||||
label: '${entry.behavior} ${entry.rule}',
|
||||
);
|
||||
}
|
||||
|
||||
for (final entry in flattened) {
|
||||
if (entry.rule == target) {
|
||||
return _RemovalResult(
|
||||
removed: true,
|
||||
settings: _removeByLabel(settings, entry.behavior, entry.rule),
|
||||
label: '${entry.behavior} ${entry.rule}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return _RemovalResult(removed: false, settings: settings, label: target);
|
||||
}
|
||||
|
||||
LocalSettings _removeByLabel(LocalSettings settings, String behavior, String rule) {
|
||||
switch (behavior) {
|
||||
case 'allow':
|
||||
return settings.copyWith(
|
||||
alwaysAllowRules: settings.alwaysAllowRules
|
||||
.where((item) => item != rule)
|
||||
.toList(growable: false),
|
||||
);
|
||||
case 'deny':
|
||||
return settings.copyWith(
|
||||
alwaysDenyRules: settings.alwaysDenyRules
|
||||
.where((item) => item != rule)
|
||||
.toList(growable: false),
|
||||
);
|
||||
case 'ask':
|
||||
return settings.copyWith(
|
||||
alwaysAskRules: settings.alwaysAskRules
|
||||
.where((item) => item != rule)
|
||||
.toList(growable: false),
|
||||
);
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
List<_PermissionEntry> _flatten(LocalSettings settings) => [
|
||||
...settings.alwaysAllowRules.map((r) => _PermissionEntry(behavior: 'allow', rule: r)),
|
||||
...settings.alwaysAskRules.map((r) => _PermissionEntry(behavior: 'ask', rule: r)),
|
||||
...settings.alwaysDenyRules.map((r) => _PermissionEntry(behavior: 'deny', rule: r)),
|
||||
];
|
||||
|
||||
int totalRuleCount(LocalSettings settings) =>
|
||||
settings.alwaysAllowRules.length +
|
||||
settings.alwaysAskRules.length +
|
||||
settings.alwaysDenyRules.length;
|
||||
|
||||
class _PermissionEntry {
|
||||
const _PermissionEntry({required this.behavior, required this.rule});
|
||||
final String behavior;
|
||||
final String rule;
|
||||
}
|
||||
|
||||
class _RemovalResult {
|
||||
const _RemovalResult({required this.label, required this.removed, required this.settings});
|
||||
final String label;
|
||||
final bool removed;
|
||||
final LocalSettings settings;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim();
|
||||
if (!context.sessionState.planModeEnabled) {
|
||||
context.sessionState.planModeEnabled = true;
|
||||
if (rawArgs.isNotEmpty && rawArgs != 'open') {
|
||||
final existingPlan = await context.sessionState.readPlan();
|
||||
if (existingPlan == null || existingPlan.trim().isEmpty) {
|
||||
await context.sessionState.writePlan('# Plan\n\nGoal:\n- $rawArgs\n');
|
||||
}
|
||||
}
|
||||
context.writeLine('Enabled plan mode');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final planContent = await context.sessionState.readPlan();
|
||||
final planPath = context.sessionState.planFilePath;
|
||||
final argList = rawArgs.isEmpty ? const <String>[] : rawArgs.split(RegExp(r'\s+'));
|
||||
|
||||
if (argList.isNotEmpty && argList.first == 'open') {
|
||||
context.writeLine('Plan file: $planPath');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (planContent == null || planContent.trim().isEmpty) {
|
||||
context.writeLine('Already in plan mode. No plan written yet.');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine('Current Plan');
|
||||
context.writeLine(planPath);
|
||||
context.writeLine('');
|
||||
context.writeLine(planContent);
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final subcmd = args.isEmpty ? "" : args.first.toLowerCase();
|
||||
|
||||
context.writeLine("Plugin Manager");
|
||||
context.writeLine("");
|
||||
|
||||
switch (subcmd) {
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
context.writeLine("Usage: /plugin [subcommand]");
|
||||
context.writeLine("");
|
||||
context.writeLine("Subcommands:");
|
||||
context.writeLine(" install [plugin] Install a plugin");
|
||||
context.writeLine(" uninstall [plugin] Uninstall a plugin");
|
||||
context.writeLine(" enable [plugin] Enable a plugin");
|
||||
context.writeLine(" disable [plugin] Disable a plugin");
|
||||
context.writeLine(" validate [path] Validate a plugin");
|
||||
context.writeLine(" marketplace Manage marketplaces");
|
||||
context.writeLine(" manage Manage installed plugins");
|
||||
break;
|
||||
|
||||
case "install":
|
||||
case "i":
|
||||
final target = args.length > 1 ? args.sublist(1).join(" ") : "";
|
||||
if (target.isEmpty) {
|
||||
context.writeLine("Usage: /plugin install <plugin-name>");
|
||||
} else {
|
||||
context.writeLine("Install target: $target");
|
||||
context.writeLine("");
|
||||
context.writeLine("Interactive plugin installation is not ported to the Dart CLI.");
|
||||
}
|
||||
break;
|
||||
|
||||
case "uninstall":
|
||||
final target = args.length > 1 ? args[1] : "";
|
||||
context.writeLine("Uninstall plugin: ${target.isEmpty ? "(interactive)" : target}");
|
||||
context.writeLine("Interactive plugin management is not ported to the Dart CLI.");
|
||||
break;
|
||||
|
||||
case "enable":
|
||||
final target = args.length > 1 ? args[1] : "";
|
||||
context.writeLine("Enable plugin: ${target.isEmpty ? "(interactive)" : target}");
|
||||
context.writeLine("Interactive plugin management is not ported to the Dart CLI.");
|
||||
break;
|
||||
|
||||
case "disable":
|
||||
final target = args.length > 1 ? args[1] : "";
|
||||
context.writeLine("Disable plugin: ${target.isEmpty ? "(interactive)" : target}");
|
||||
context.writeLine("Interactive plugin management is not ported to the Dart CLI.");
|
||||
break;
|
||||
|
||||
case "validate":
|
||||
final path = args.length > 1 ? args.sublist(1).join(" ") : "";
|
||||
context.writeLine("Validate plugin${path.isEmpty ? "" : " at: $path"}");
|
||||
context.writeLine("Interactive plugin validation is not ported to the Dart CLI.");
|
||||
break;
|
||||
|
||||
case "marketplace":
|
||||
case "market":
|
||||
context.writeLine("Marketplace management is not ported to the Dart CLI.");
|
||||
break;
|
||||
|
||||
default:
|
||||
context.writeLine("The interactive plugin browser is not ported to the Dart CLI runtime.");
|
||||
context.writeLine("Run /plugin help to see available subcommands.");
|
||||
}
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final prArg = args.join(' ').trim();
|
||||
|
||||
context.writeLine('PR Comments');
|
||||
context.writeLine('');
|
||||
|
||||
if (prArg.isEmpty) {
|
||||
context.writeLine('Usage: /pr-comments [pr-number]');
|
||||
context.writeLine('');
|
||||
context.writeLine('Fetches and displays comments from a GitHub pull request.');
|
||||
context.writeLine('Requires the `gh` CLI to be installed and authenticated.');
|
||||
} else {
|
||||
context.writeLine(
|
||||
'This is a prompt-type command. In the legacy CLI it sends a prompt to the model'
|
||||
' asking it to fetch and format PR comments via the gh CLI.',
|
||||
);
|
||||
context.writeLine('PR: $prArg');
|
||||
}
|
||||
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'Hint: run `gh pr view $prArg` and `gh api /repos/.../pulls/$prArg/comments` manually.',
|
||||
);
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim().toLowerCase();
|
||||
|
||||
if (commonHelpArgs.contains(rawArgs)) {
|
||||
context.writeLine(
|
||||
'Usage: /privacy-settings\n\n'
|
||||
'View and update your privacy settings.\n'
|
||||
'Controls things like telemetry and data retention.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final settings = context.settingsStore.settings;
|
||||
|
||||
context.writeLine('Privacy settings');
|
||||
context.writeLine('');
|
||||
context.writeLine(' telemetry: ${settings.telemetry ?? 'default (on)'}');
|
||||
context.writeLine(' privacyLevel: ${settings.privacyLevel ?? 'standard'}');
|
||||
context.writeLine('');
|
||||
context.writeLine('To change, edit your settings file: ${context.settingsStore.path}');
|
||||
context.writeLine('Or use /config to inspect the full settings object.');
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../command.dart';
|
||||
import '../daemon/daemon_manager.dart';
|
||||
import '../daemon/daemon_types.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final mgr = DaemonManager();
|
||||
final sessions = await mgr.listSessions(refreshStatus: true);
|
||||
|
||||
if (sessions.isEmpty) {
|
||||
context.writeLine("No background sessions found.");
|
||||
return const CommandResult(exitCode: 0);
|
||||
}
|
||||
|
||||
context.writeLine("Background Sessions:");
|
||||
context.writeLine("");
|
||||
|
||||
for (final s in sessions) {
|
||||
final alive = s.status == SessionStatus.running ? " (running)" : " (${s.status.name})";
|
||||
final title = s.title != null ? " ${s.title}" : "";
|
||||
context.writeLine(" ${s.id} pid=${s.pid}$alive$title");
|
||||
context.writeLine(" dir: ${s.workingDirectory}");
|
||||
context.writeLine(" started: ${s.startedAt}");
|
||||
if (s.endedAt != null) context.writeLine(" ended: ${s.endedAt}");
|
||||
}
|
||||
|
||||
return const CommandResult(exitCode: 0);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import '../build_info.dart';
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
const changelogUrl = 'https://github.com/anthropics/claude-code/releases';
|
||||
|
||||
context.writeLine('Release Notes');
|
||||
context.writeLine('');
|
||||
context.writeLine('Current version: ${BuildInfo.versionDisplay}');
|
||||
context.writeLine('');
|
||||
context.writeLine('Fetching the remote changelog is not wired up in the Dart CLI runtime yet.');
|
||||
context.writeLine('See the full changelog at: $changelogUrl');
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import '../command.dart';
|
||||
import '../session/session_store.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final newName = args.join(" ").trim();
|
||||
|
||||
if (newName.isEmpty || commonHelpArgs.contains(newName.toLowerCase())) {
|
||||
context.writeLine(
|
||||
'Usage: /rename <name>\n\n'
|
||||
'Rename the current conversation session.\n'
|
||||
'If no name is given in the legacy CLI, one is auto-generated from context.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.sessionState.sessionName = newName;
|
||||
|
||||
if (history.hasSession) {
|
||||
history.session!.name = newName;
|
||||
await SessionStore.instance.saveSession(history.session!);
|
||||
}
|
||||
|
||||
context.writeLine('Session renamed to: "$newName"');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import '../command.dart';
|
||||
import '../session/session_store.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final query = args.join(' ').trim();
|
||||
final sessions = await SessionStore.instance.listSessionsForProject(context.workingDirectory);
|
||||
|
||||
if (sessions.isEmpty) {
|
||||
context.writeLine("No saved sessions found.");
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final filtered = query.isEmpty
|
||||
? sessions
|
||||
: sessions.where((s) {
|
||||
final lower = query.toLowerCase();
|
||||
return s.name.toLowerCase().contains(lower) || s.id.startsWith(lower);
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
context.writeLine('No sessions matching "$query".');
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
|
||||
context.writeLine("Saved sessions (newest first):");
|
||||
context.writeLine("");
|
||||
|
||||
for (int i = 0; i < filtered.length; i++) {
|
||||
final s = filtered[i];
|
||||
final ts = s.updated.toLocal().toString().substring(0, 16);
|
||||
final costStr = s.cost != null ? " \$${s.cost!.toStringAsFixed(4)}" : "";
|
||||
context.writeLine(" [${i + 1}] ${s.name}$costStr");
|
||||
context.writeLine(" id=${s.id} msgs=${s.messageCount} updated=$ts");
|
||||
}
|
||||
|
||||
context.writeLine("\nTo load a session, use: /resume <name or id>");
|
||||
|
||||
if (filtered.length == 1 && query.isNotEmpty) {
|
||||
final loaded = await SessionStore.instance.loadSession(
|
||||
filtered.first.id,
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
if (loaded != null) {
|
||||
history.setSession(loaded);
|
||||
context.sessionState.sessionName = loaded.name;
|
||||
context.writeLine('\nResumed session: "${loaded.name}"');
|
||||
}
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final prArg = args.join(' ').trim();
|
||||
|
||||
context.writeLine('Review pull request');
|
||||
context.writeLine('');
|
||||
|
||||
if (prArg.isEmpty) {
|
||||
context.writeLine('Usage: /review [pr-number]');
|
||||
context.writeLine('');
|
||||
context.writeLine('No PR number given. In the legacy CLI this would run `gh pr list` first.');
|
||||
} else {
|
||||
context.writeLine('PR: $prArg');
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'This is a prompt-type command. In the legacy CLI it sends a review prompt to the model'
|
||||
' with the gh pr diff output embedded.',
|
||||
);
|
||||
}
|
||||
|
||||
context.writeLine('');
|
||||
context.writeLine('Hint: run `gh pr diff $prArg` to see the diff manually.');
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Rewind / Checkpoint");
|
||||
context.writeLine("");
|
||||
context.writeLine("Restore the code and conversation to a previous checkpoint.");
|
||||
context.writeLine("");
|
||||
context.writeLine("This command requires an active REPL session with checkpoint history.");
|
||||
context.writeLine(
|
||||
"The interactive checkpoint selector is not ported to the Dart CLI runtime.",
|
||||
);
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Security review");
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"This is a prompt-type command. In the legacy CLI it sends the current\n"
|
||||
"git diff to the AI model for a focused security analysis.",
|
||||
);
|
||||
context.writeLine("");
|
||||
context.writeLine("Security categories examined:");
|
||||
context.writeLine(" - Input validation (SQLi, CMDi, path traversal, etc.)");
|
||||
context.writeLine(" - Authentication & authorization issues");
|
||||
context.writeLine(" - Crypto & secrets management");
|
||||
context.writeLine(" - Injection & code execution");
|
||||
context.writeLine(" - Data exposure");
|
||||
context.writeLine("");
|
||||
|
||||
try {
|
||||
final diffResult = await Process.run(
|
||||
"git", ["diff", "--stat", "origin/HEAD..."],
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
final stat = (diffResult.stdout as String).trim();
|
||||
if (stat.isNotEmpty) {
|
||||
context.writeLine("Changes vs origin/HEAD:");
|
||||
context.writeLine(stat);
|
||||
} else {
|
||||
context.writeLine("(no diff vs origin/HEAD detected)");
|
||||
}
|
||||
} on ProcessException {
|
||||
context.writeLine("(could not run git diff)");
|
||||
}
|
||||
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"Run `git diff origin/HEAD...` to view the full diff, then review manually or use the legacy CLI.",
|
||||
);
|
||||
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Remote Session");
|
||||
context.writeLine("");
|
||||
context.writeLine("Remote session mode is not available in the Dart CLI port.");
|
||||
context.writeLine(
|
||||
"This command shows a QR code and URL when Claude Code is running in remote mode.",
|
||||
);
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Skills");
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"Skills are reusable prompt templates that can be invoked as slash commands.",
|
||||
);
|
||||
context.writeLine("The interactive skills browser is not ported to the Dart CLI runtime.");
|
||||
context.writeLine("");
|
||||
context.writeLine(
|
||||
"In the legacy CLI, skills are loaded from ~/.claude/skills/ or project .claude/skills/.",
|
||||
);
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final stats = context.runtimeStateStore.state.stats;
|
||||
final sortedCounts = stats.commandCounts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
context.writeLine('CLI stats');
|
||||
context.writeLine('Sessions started: ${stats.sessionsStarted}');
|
||||
context.writeLine('Interactive sessions: ${stats.interactiveSessionsStarted}');
|
||||
context.writeLine('Commands executed: ${stats.commandsExecuted}');
|
||||
context.writeLine('Commands this session: ${context.sessionState.commandsExecuted}');
|
||||
context.writeLine(
|
||||
'Session duration: ${formatDuration(DateTime.now().toUtc().difference(context.sessionState.startedAt))}',
|
||||
);
|
||||
if (stats.lastCommandName != null) {
|
||||
context.writeLine(
|
||||
'Last command: ${stats.lastCommandName} (${stats.lastCommandAt ?? 'unknown'})',
|
||||
);
|
||||
}
|
||||
if (sortedCounts.isNotEmpty) {
|
||||
context.writeLine('');
|
||||
context.writeLine('Top commands:');
|
||||
for (final entry in sortedCounts.take(5)) {
|
||||
context.writeLine(' ${entry.key}: ${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import '../build_info.dart';
|
||||
import '../command.dart';
|
||||
import '../legacy_inventory.dart' show legacySourceFileCount;
|
||||
import '../migration_assessment.dart';
|
||||
import 'permissions.dart' as permissionsCmd;
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final unportedCommands = context.catalog.unportedSlashCommands;
|
||||
final sample = unportedCommands.take(10).map((c) => c.name).join(', ');
|
||||
final auth = context.runtimeStateStore.state.auth;
|
||||
|
||||
context.writeLine('Claude Code status');
|
||||
context.writeLine('Version: ${BuildInfo.versionDisplay}');
|
||||
context.writeLine('Working directory: ${context.workingDirectory}');
|
||||
context.writeLine(
|
||||
'Account: ${auth == null ? 'not logged in' : '${auth.email} (${auth.subscriptionType}${auth.rateLimitTier == null ? '' : ', ${auth.rateLimitTier}'})'}',
|
||||
);
|
||||
context.writeLine('Model: ${renderModelSetting(resolveCurrentModelSetting(context))}');
|
||||
context.writeLine('Permission mode: ${context.settingsStore.settings.permissionMode}');
|
||||
context.writeLine(
|
||||
'Permission rules: ${permissionsCmd.totalRuleCount(context.settingsStore.settings)}',
|
||||
);
|
||||
context.writeLine(
|
||||
'Fast mode: ${context.settingsStore.settings.fastMode ? 'on' : 'off'}',
|
||||
);
|
||||
context.writeLine(
|
||||
'Effort: ${showCurrentEffort(context).replaceFirst('Current ', '').replaceFirst('Effort ', 'effort ')}',
|
||||
);
|
||||
context.writeLine(
|
||||
'Statusline prompt: ${context.settingsStore.settings.statusLinePrompt ?? defaultStatuslinePrompt}',
|
||||
);
|
||||
context.writeLine('');
|
||||
context.writeLine('Migration status');
|
||||
context.writeLine('Legacy source root: old_repo/');
|
||||
context.writeLine('Legacy source files: $legacySourceFileCount');
|
||||
context.writeLine('Known slash commands: ${context.catalog.totalKnownSlashCommands}');
|
||||
context.writeLine('Ported commands: ${context.catalog.portedCommands.length}');
|
||||
context.writeLine(
|
||||
'Reserved top-level entrypoints: ${context.catalog.totalReservedTopLevelEntryPoints}',
|
||||
);
|
||||
context.writeLine('Remaining slash commands: ${unportedCommands.length}');
|
||||
if (sample.isNotEmpty) context.writeLine('Next unported commands: $sample');
|
||||
context.writeLine(
|
||||
'Largest legacy areas: ${legacySubsystemStats.take(5).map((stat) => '${stat.name} ${stat.fileCount}').join(', ')}',
|
||||
);
|
||||
context.writeLine('High-friction import matches: $legacyHotspotImportMatches');
|
||||
context.writeLine('Primary blockers:');
|
||||
for (final blocker in migrationBlockers.take(3)) {
|
||||
context.writeLine(' - $blocker');
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim();
|
||||
if (rawArgs.isEmpty || commonInfoArgs.contains(rawArgs.toLowerCase())) {
|
||||
final prompt = context.settingsStore.settings.statusLinePrompt ?? defaultStatuslinePrompt;
|
||||
context.writeLine('Status line prompt: $prompt');
|
||||
context.writeLine(buildStatuslineAgentInstruction(prompt));
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (commonHelpArgs.contains(rawArgs.toLowerCase())) {
|
||||
context.writeLine('Usage: /statusline [show|clear|<prompt>]');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (rawArgs.toLowerCase() == 'clear') {
|
||||
await context.settingsStore.update(
|
||||
(settings) => settings.copyWith(statusLinePrompt: null),
|
||||
);
|
||||
context.writeLine('Cleared saved status line prompt.');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
await context.settingsStore.update(
|
||||
(settings) => settings.copyWith(statusLinePrompt: rawArgs),
|
||||
);
|
||||
context.writeLine(buildStatuslineAgentInstruction(rawArgs));
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
const url = "https://www.stickermule.com/claudecode";
|
||||
|
||||
String? browserCmd;
|
||||
if (Platform.isMacOS) {
|
||||
browserCmd = "open";
|
||||
} else if (Platform.isLinux) {
|
||||
browserCmd = "xdg-open";
|
||||
} else if (Platform.isWindows) {
|
||||
browserCmd = "start";
|
||||
}
|
||||
|
||||
bool opened = false;
|
||||
if (browserCmd != null) {
|
||||
try {
|
||||
final result = await Process.run(browserCmd, [url]);
|
||||
opened = result.exitCode == 0;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (opened) {
|
||||
context.writeLine("Opening sticker page in browser...");
|
||||
} else {
|
||||
context.writeLine("Order Claude Code stickers at: $url");
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final tagArg = args.join(" ").trim();
|
||||
|
||||
if (tagArg.isEmpty || commonHelpArgs.contains(tagArg) || commonInfoArgs.contains(tagArg)) {
|
||||
context.writeLine(
|
||||
'Usage: /tag <tag-name>\n\n'
|
||||
'Toggle a searchable tag on the current session.\n'
|
||||
'Run the same command again to remove the tag.\n'
|
||||
'Tags are displayed after the branch name in /resume and can be searched with /.\n\n'
|
||||
'Examples:\n'
|
||||
' /tag bugfix # Add tag\n'
|
||||
' /tag bugfix # Remove tag (toggle)\n'
|
||||
' /tag feature-auth\n'
|
||||
' /tag wip',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
final normalizedTag = tagArg.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '').trim();
|
||||
if (normalizedTag.isEmpty) {
|
||||
context.writeLine("Tag name cannot be empty");
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
final currentTag = context.sessionState.sessionTag;
|
||||
if (currentTag == normalizedTag) {
|
||||
context.sessionState.sessionTag = null;
|
||||
context.writeLine("Removed tag #$normalizedTag");
|
||||
} else {
|
||||
context.sessionState.sessionTag = normalizedTag;
|
||||
if (currentTag != null) {
|
||||
context.writeLine("Replaced tag #$currentTag with #$normalizedTag");
|
||||
} else {
|
||||
context.writeLine("Tagged session with #$normalizedTag");
|
||||
}
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Background Tasks");
|
||||
context.writeLine("");
|
||||
context.writeLine("The interactive task manager is not ported to the Dart CLI runtime.");
|
||||
context.writeLine("Background task tracking requires a running REPL session.");
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../command.dart';
|
||||
|
||||
const _nativeCsiuTerminals = <String>[
|
||||
'ghostty', 'kitty', 'iTerm.app', 'WezTerm', 'WarpTerminal',
|
||||
];
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final term = Platform.environment['TERM_PROGRAM']
|
||||
?? Platform.environment['TERMINAL_EMULATOR']
|
||||
?? '';
|
||||
|
||||
if (_nativeCsiuTerminals.contains(term)) {
|
||||
context.writeLine(
|
||||
'Terminal-setup: your terminal ($term) natively supports the Kitty keyboard protocol.',
|
||||
);
|
||||
context.writeLine('No additional setup is needed for Shift+Enter / newlines.');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine('Terminal setup');
|
||||
context.writeLine('');
|
||||
|
||||
if (Platform.isMacOS && term == 'Apple_Terminal') {
|
||||
context.writeLine('Detected: Apple Terminal (macOS)');
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'To enable Option+Enter for newlines:\n'
|
||||
' 1. Open Terminal > Settings > Profiles > Keyboard\n'
|
||||
' 2. Add a key binding: Option+Return → sends \\033\\012\n'
|
||||
' 3. Alternatively run the legacy CLI interactively: /terminal-setup',
|
||||
);
|
||||
} else if (term == 'vscode' || term == 'cursor' || term == 'windsurf') {
|
||||
context.writeLine('Detected: $term terminal');
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'To enable Shift+Enter for newlines, add this to your $term keybindings.json:\n'
|
||||
' { "key": "shift+enter", "command": "workbench.action.terminal.sendSequence",\n'
|
||||
' "args": { "text": "\\\\n" }, "when": "terminalFocus" }',
|
||||
);
|
||||
} else {
|
||||
context.writeLine('Detected terminal: ${term.isEmpty ? '(unknown)' : term}');
|
||||
context.writeLine('');
|
||||
context.writeLine(
|
||||
'Interactive terminal setup (key binding installation) is not ported to the Dart runtime.',
|
||||
);
|
||||
context.writeLine('Run the legacy CLI to use the full interactive setup wizard.');
|
||||
}
|
||||
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import '../command.dart';
|
||||
import '../local_state.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final rawArgs = args.join(' ').trim().toLowerCase();
|
||||
if (rawArgs.isEmpty || rawArgs == 'current' || rawArgs == 'status') {
|
||||
context.writeLine('Current theme: ${context.settingsStore.settings.theme}');
|
||||
context.writeLine('Available themes: ${supportedThemeSettings.join(', ')}');
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
if (!supportedThemeSettings.contains(rawArgs)) {
|
||||
context.writeLine(
|
||||
'Invalid theme "$rawArgs". Available themes: ${supportedThemeSettings.join(', ')}',
|
||||
);
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
await context.settingsStore.update((settings) => settings.copyWith(theme: rawArgs));
|
||||
context.writeLine('Theme set to $rawArgs');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../command.dart';
|
||||
import '../tools/tool_registry.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final registry = ToolRegistry();
|
||||
|
||||
context.writeLine('Available tools:');
|
||||
context.writeLine('');
|
||||
|
||||
for (final tool in registry.allTools) {
|
||||
context.writeLine(' ${tool.name}');
|
||||
context.writeLine(' ${tool.description}');
|
||||
context.writeLine('');
|
||||
}
|
||||
|
||||
context.writeLine('Usage: toolname: <args> (e.g. bash: echo hello)');
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final auth = context.runtimeStateStore.state.auth;
|
||||
if (auth != null &&
|
||||
auth.subscriptionType == 'max' &&
|
||||
auth.rateLimitTier == max20xTier) {
|
||||
context.writeLine(
|
||||
'You are already on the highest Max subscription plan. For additional usage, run /login to switch to an API usage-billed account.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
|
||||
context.writeLine('Upgrade URL: https://claude.ai/upgrade/max');
|
||||
if (auth != null) {
|
||||
context.writeLine(
|
||||
'After upgrading, refresh the local profile with /login ${auth.email} max $max20xTier',
|
||||
);
|
||||
}
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import '../command.dart';
|
||||
import '_shared.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final auth = context.runtimeStateStore.state.auth;
|
||||
context.writeLine('Plan usage');
|
||||
if (auth == null) {
|
||||
context.writeLine('Account: not logged in');
|
||||
} else {
|
||||
context.writeLine('Account: ${auth.email}');
|
||||
context.writeLine('Subscription: ${auth.subscriptionType}');
|
||||
context.writeLine('Rate limit tier: ${auth.rateLimitTier ?? 'not recorded'}');
|
||||
context.writeLine('Logged in at: ${auth.loggedInAt}');
|
||||
}
|
||||
context.writeLine('Model: ${renderModelSetting(resolveCurrentModelSetting(context))}');
|
||||
context.writeLine('Fast mode: ${context.settingsStore.settings.fastMode ? 'on' : 'off'}');
|
||||
context.writeLine(showCurrentEffort(context));
|
||||
context.writeLine(
|
||||
'Remote quota sync is not available in the Dart CLI yet, so this view shows saved account metadata only.',
|
||||
);
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import '../build_info.dart';
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine(BuildInfo.versionDisplay);
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
final currentMode = context.settingsStore.settings.editorMode == 'vim' ? 'vim' : 'normal';
|
||||
final newMode = currentMode == 'normal' ? 'vim' : 'normal';
|
||||
await context.settingsStore.update((settings) => settings.copyWith(editorMode: newMode));
|
||||
|
||||
if (newMode == 'vim') {
|
||||
context.writeLine(
|
||||
'Editor mode set to vim. Use Escape key to toggle between INSERT and NORMAL modes.',
|
||||
);
|
||||
} else {
|
||||
context.writeLine('Editor mode set to normal. Using standard (readline) keyboard bindings.');
|
||||
}
|
||||
return const CommandResult();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import '../command.dart';
|
||||
|
||||
Future<CommandResult> run(CommandContext context, List<String> args) async {
|
||||
context.writeLine("Voice Mode");
|
||||
context.writeLine("");
|
||||
context.writeLine("Voice mode requires a Claude.ai account and is only available");
|
||||
context.writeLine("in the interactive REPL session, not the Dart CLI port.");
|
||||
context.writeLine("");
|
||||
context.writeLine("Sign in at https://claude.ai to access voice features.");
|
||||
return const CommandResult(exitCode: 2);
|
||||
}
|
||||
@@ -71,11 +71,12 @@ String joinPath(String base, String child) {
|
||||
class LocalSettings {
|
||||
const LocalSettings({
|
||||
this.advisorModel,
|
||||
this.advisorEffortLevel,
|
||||
this.alwaysAllowRules = const <String>[],
|
||||
this.alwaysAskRules = const <String>[],
|
||||
this.alwaysDenyRules = const <String>[],
|
||||
this.editorMode = 'normal',
|
||||
this.effortLevel,
|
||||
this.effortLevel = 'medium',
|
||||
this.fastMode = false,
|
||||
this.hooks,
|
||||
this.mcpServers,
|
||||
@@ -92,11 +93,12 @@ class LocalSettings {
|
||||
factory LocalSettings.fromJson(Map<String, dynamic> json) {
|
||||
return LocalSettings(
|
||||
advisorModel: _readString(json, 'advisorModel'),
|
||||
advisorEffortLevel: _readString(json, 'advisorEffortLevel'),
|
||||
alwaysAllowRules: _readStringList(json, 'alwaysAllowRules'),
|
||||
alwaysAskRules: _readStringList(json, 'alwaysAskRules'),
|
||||
alwaysDenyRules: _readStringList(json, 'alwaysDenyRules'),
|
||||
editorMode: _readString(json, 'editorMode') ?? 'normal',
|
||||
effortLevel: _readString(json, 'effortLevel'),
|
||||
effortLevel: _readString(json, 'effortLevel') ?? 'medium',
|
||||
fastMode: _readBool(json, 'fastMode') ?? false,
|
||||
hooks: _readStringMap(json, 'hooks'),
|
||||
mcpServers: _readMcpServers(json),
|
||||
@@ -113,11 +115,12 @@ class LocalSettings {
|
||||
|
||||
// advisor model name - optional
|
||||
final String? advisorModel;
|
||||
final String? advisorEffortLevel;
|
||||
final List<String> alwaysAllowRules;
|
||||
final List<String> alwaysAskRules;
|
||||
final List<String> alwaysDenyRules;
|
||||
final String editorMode;
|
||||
final String? effortLevel;
|
||||
final String effortLevel;
|
||||
final bool fastMode;
|
||||
|
||||
// hook configs keyed by event name
|
||||
@@ -136,6 +139,7 @@ class LocalSettings {
|
||||
|
||||
LocalSettings copyWith({
|
||||
Object? advisorModel = _sentinel,
|
||||
Object? advisorEffortLevel = _sentinel,
|
||||
List<String>? alwaysAllowRules,
|
||||
List<String>? alwaysAskRules,
|
||||
List<String>? alwaysDenyRules,
|
||||
@@ -155,13 +159,14 @@ class LocalSettings {
|
||||
}) {
|
||||
return LocalSettings(
|
||||
advisorModel: identical(advisorModel, _sentinel) ? this.advisorModel : advisorModel as String?,
|
||||
advisorEffortLevel: identical(advisorEffortLevel, _sentinel) ? this.advisorEffortLevel : advisorEffortLevel as String?,
|
||||
alwaysAllowRules: alwaysAllowRules ?? this.alwaysAllowRules,
|
||||
alwaysAskRules: alwaysAskRules ?? this.alwaysAskRules,
|
||||
alwaysDenyRules: alwaysDenyRules ?? this.alwaysDenyRules,
|
||||
editorMode: editorMode ?? this.editorMode,
|
||||
effortLevel: identical(effortLevel, _sentinel)
|
||||
? this.effortLevel
|
||||
: effortLevel as String?,
|
||||
: (effortLevel as String?) ?? 'medium',
|
||||
fastMode: fastMode ?? this.fastMode,
|
||||
hooks: identical(hooks, _sentinel) ? this.hooks : hooks as Map<String, String>?,
|
||||
mcpServers: identical(mcpServers, _sentinel) ? this.mcpServers : mcpServers as Map<String, Map<String, dynamic>>?,
|
||||
@@ -189,11 +194,12 @@ class LocalSettings {
|
||||
|
||||
return LocalSettings(
|
||||
advisorModel: override.advisorModel ?? advisorModel,
|
||||
advisorEffortLevel: override.advisorEffortLevel ?? advisorEffortLevel,
|
||||
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,
|
||||
effortLevel: override.effortLevel != 'medium' ? override.effortLevel : effortLevel,
|
||||
fastMode: override.fastMode ? true : fastMode,
|
||||
hooks: override.hooks ?? hooks,
|
||||
mcpServers: override.mcpServers ?? mcpServers,
|
||||
@@ -211,6 +217,7 @@ class LocalSettings {
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'advisorModel': advisorModel,
|
||||
'advisorEffortLevel': advisorEffortLevel,
|
||||
'alwaysAllowRules': alwaysAllowRules,
|
||||
'alwaysAskRules': alwaysAskRules,
|
||||
'alwaysDenyRules': alwaysDenyRules,
|
||||
|
||||
@@ -21,10 +21,10 @@ class ConversationHistory {
|
||||
_session = s;
|
||||
}
|
||||
|
||||
void addMessage(String role, String content, {int? tokens, int? contextTokens, List<MessageAttachment>? attachments}) {
|
||||
void addMessage(String role, String content, {int? tokens, int? contextTokens, List<MessageAttachment>? attachments, double? cost}) {
|
||||
if (_session == null) return;
|
||||
|
||||
final msg = Message(role: role, content: content, tokens: tokens, contextTokens: contextTokens, attachments: attachments);
|
||||
final msg = Message(role: role, content: content, tokens: tokens, contextTokens: contextTokens, attachments: attachments, cost: cost);
|
||||
|
||||
_session!.messages.add(msg);
|
||||
_session!.updated = DateTime.now().toUtc();
|
||||
@@ -46,7 +46,7 @@ class ConversationHistory {
|
||||
_session!.updated = DateTime.now().toUtc();
|
||||
}
|
||||
|
||||
void setLastMessageContextTokens(int contextTokens) {
|
||||
void setLastMessageContextTokens(int? contextTokens) {
|
||||
if (_session == null || _session!.messages.isEmpty) return;
|
||||
final last = _session!.messages.last;
|
||||
_session!.messages[_session!.messages.length - 1] = Message(
|
||||
@@ -54,7 +54,21 @@ class ConversationHistory {
|
||||
content: last.content,
|
||||
timestamp: last.timestamp,
|
||||
tokens: last.tokens,
|
||||
contextTokens: contextTokens,
|
||||
contextTokens: contextTokens ?? last.contextTokens,
|
||||
cost: last.cost,
|
||||
);
|
||||
}
|
||||
|
||||
void setLastMessageCost(double? cost) {
|
||||
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: last.contextTokens,
|
||||
cost: cost,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import "dart:convert";
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "../api/openrouter_client.dart";
|
||||
import "../api/response_parser.dart";
|
||||
import "../compact/compact_service.dart";
|
||||
import "../hooks/hook_runner.dart";
|
||||
import "../hooks/hook_types.dart";
|
||||
@@ -32,11 +33,19 @@ class SessionRuntime {
|
||||
required LocalSettings Function() getSettings,
|
||||
required String Function(String?) normalizeModelId,
|
||||
required VoidCallback onChanged,
|
||||
void Function(double costDelta)? onCostAdded,
|
||||
bool Function()? isActive,
|
||||
void Function(String sessionId, String name)? onNameGenerated,
|
||||
Future<void> Function(String rule)? onPersistAllowRule,
|
||||
}) : _toolLoopService = toolLoopService,
|
||||
_hookRunner = hookRunner,
|
||||
_getSettings = getSettings,
|
||||
_normalizeModelId = normalizeModelId,
|
||||
_onChanged = onChanged {
|
||||
_onChanged = onChanged,
|
||||
_onCostAdded = onCostAdded,
|
||||
_isActive = isActive,
|
||||
_onNameGenerated = onNameGenerated,
|
||||
_onPersistAllowRule = onPersistAllowRule {
|
||||
_conversationHistory = ConversationHistory(session: session);
|
||||
_apiMessages = _buildApiMessages(session.messages);
|
||||
// restore persisted per-thread mode override
|
||||
@@ -46,8 +55,14 @@ class SessionRuntime {
|
||||
final ToolLoopService _toolLoopService;
|
||||
final HookRunner? _hookRunner;
|
||||
final VoidCallback _onChanged;
|
||||
final void Function(double costDelta)? _onCostAdded;
|
||||
final LocalSettings Function() _getSettings;
|
||||
final String Function(String?) _normalizeModelId;
|
||||
final bool Function()? _isActive;
|
||||
final void Function(String sessionId, String name)? _onNameGenerated;
|
||||
final Future<void> Function(String rule)? _onPersistAllowRule;
|
||||
|
||||
bool _nameGenerated = false;
|
||||
|
||||
late final ConversationHistory _conversationHistory;
|
||||
late List<Map<String, dynamic>> _apiMessages;
|
||||
@@ -79,6 +94,10 @@ class SessionRuntime {
|
||||
// set when a turn finishes while the user is viewing a different thread
|
||||
bool _hasUnreadResult = false;
|
||||
|
||||
// true while a streaming tool is actively pushing chunks — prevents onToolResult
|
||||
// from double-adding the content that was already appended chunk by chunk
|
||||
bool _streamingToolOutput = false;
|
||||
|
||||
// compact state
|
||||
String? _lastCompactSummary;
|
||||
bool _suppressCompactWarning = false;
|
||||
@@ -102,6 +121,11 @@ class SessionRuntime {
|
||||
PendingPermission? get pendingPermission => _pendingPermission;
|
||||
bool get hasUnreadResult => _hasUnreadResult;
|
||||
|
||||
void setUnreadResult(bool value) {
|
||||
_hasUnreadResult = value;
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
void markRead() {
|
||||
if (!_hasUnreadResult) return;
|
||||
_hasUnreadResult = false;
|
||||
@@ -204,6 +228,9 @@ class SessionRuntime {
|
||||
bool hasStreamingAssistantMessage = false;
|
||||
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
|
||||
|
||||
// detect first turn before the user message is added
|
||||
final bool isFirstTurn = _apiMessages.isEmpty && !_nameGenerated;
|
||||
|
||||
final session = _conversationHistory.session;
|
||||
if (session != null) {
|
||||
session.model = model;
|
||||
@@ -282,13 +309,28 @@ class SessionRuntime {
|
||||
);
|
||||
_onChanged();
|
||||
},
|
||||
onToolResult: (toolName, result) {
|
||||
_conversationHistory.addMessage(
|
||||
"tool",
|
||||
_formatToolResult(toolName, result),
|
||||
);
|
||||
onToolOutputChunk: (toolName, chunk) {
|
||||
// append live chunk to the last tool message (which onToolCall just added)
|
||||
_streamingToolOutput = true;
|
||||
_conversationHistory.appendToLastMessage(chunk);
|
||||
_onChanged();
|
||||
},
|
||||
onToolResult: (toolName, result) {
|
||||
if (_streamingToolOutput) {
|
||||
// content already in the message from live chunks — dont double-add
|
||||
_streamingToolOutput = false;
|
||||
} else {
|
||||
_conversationHistory.addMessage(
|
||||
"tool",
|
||||
_formatToolResult(toolName, result),
|
||||
);
|
||||
}
|
||||
_onChanged();
|
||||
|
||||
// save after each tool result so progress isnt lost if app dies mid-turn
|
||||
final s = _conversationHistory.session;
|
||||
if (s != null) SessionStore.instance.saveSession(s);
|
||||
},
|
||||
onAssistantTextDelta: (delta) {
|
||||
if (!hasStreamingAssistantMessage) {
|
||||
_conversationHistory.addMessage("assistant", "");
|
||||
@@ -300,6 +342,10 @@ class SessionRuntime {
|
||||
onAssistantMessageComplete: () {
|
||||
hasStreamingAssistantMessage = false;
|
||||
_onChanged();
|
||||
|
||||
// save after each complete assistant message (streaming done)
|
||||
final s = _conversationHistory.session;
|
||||
if (s != null) SessionStore.instance.saveSession(s);
|
||||
},
|
||||
onPermissionRequired: (toolName, input, {String? suggestionRule}) async {
|
||||
final pending = PendingPermission(
|
||||
@@ -325,27 +371,58 @@ class SessionRuntime {
|
||||
|
||||
final ct = toolLoopResult.response.contextTokens;
|
||||
|
||||
final rawUsage = toolLoopResult.response.usage;
|
||||
final responseCost = (rawUsage?["cost"] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
double advisorCostTotal = 0;
|
||||
for (final au in toolLoopResult.advisorUsages) {
|
||||
cost_tracker.addToTotalSessionCost(
|
||||
cost: au.costUsd,
|
||||
inputTokens: au.inputTokens,
|
||||
outputTokens: au.outputTokens,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
model: au.model,
|
||||
);
|
||||
advisorCostTotal += au.costUsd;
|
||||
}
|
||||
|
||||
final totalCostThisTurn = responseCost + advisorCostTotal;
|
||||
|
||||
cost_tracker.addToTotalSessionCost(
|
||||
cost: responseCost,
|
||||
inputTokens: toolLoopResult.response.inputTokens ?? 0,
|
||||
outputTokens: toolLoopResult.response.outputTokens ?? 0,
|
||||
cacheReadTokens: toolLoopResult.response.cacheReadInputTokens ?? 0,
|
||||
cacheCreationTokens: toolLoopResult.response.cacheCreationInputTokens ?? 0,
|
||||
webSearchRequests: toolLoopResult.webSearchRequests,
|
||||
webFetchRequests: toolLoopResult.webFetchRequests,
|
||||
model: toolLoopResult.response.model,
|
||||
);
|
||||
|
||||
if (!toolLoopResult.finalResponseWasStreamed) {
|
||||
_conversationHistory.addMessage(
|
||||
"assistant",
|
||||
toolLoopResult.responseText,
|
||||
tokens: toolLoopResult.response.outputTokens,
|
||||
contextTokens: ct,
|
||||
cost: totalCostThisTurn > 0 ? totalCostThisTurn : null,
|
||||
);
|
||||
} else {
|
||||
_conversationHistory.setLastMessageContextTokens(ct);
|
||||
_conversationHistory.setLastMessageCost(
|
||||
totalCostThisTurn > 0 ? totalCostThisTurn : null,
|
||||
);
|
||||
}
|
||||
|
||||
cost_tracker.addToTotalSessionCost(
|
||||
cost: 0.0,
|
||||
inputTokens: toolLoopResult.response.inputTokens ?? 0,
|
||||
outputTokens: toolLoopResult.response.outputTokens ?? 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
webSearchRequests: toolLoopResult.webSearchRequests,
|
||||
webFetchRequests: toolLoopResult.webFetchRequests,
|
||||
model: toolLoopResult.response.model,
|
||||
);
|
||||
if (totalCostThisTurn > 0) _onCostAdded?.call(totalCostThisTurn);
|
||||
_onChanged();
|
||||
|
||||
// generate an AI name for the thread after the first turn
|
||||
if (isFirstTurn && session != null && _onNameGenerated != null) {
|
||||
_nameGenerated = true;
|
||||
_generateThreadName(session, text, apiKey, model);
|
||||
}
|
||||
|
||||
// auto-compact
|
||||
if (ct > 0) {
|
||||
@@ -405,7 +482,9 @@ class SessionRuntime {
|
||||
_client = null;
|
||||
_stopRequested = false;
|
||||
_isLoading = false;
|
||||
_hasUnreadResult = true;
|
||||
if (_conversationHistory.session?.id != null && !(_isActive?.call() ?? false)) {
|
||||
_hasUnreadResult = true;
|
||||
}
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
@@ -485,30 +564,58 @@ class SessionRuntime {
|
||||
_suppressCompactWarning = true;
|
||||
_consecutiveCompactFailures = 0;
|
||||
|
||||
// store a boundary marker so that on session restore we know where to
|
||||
// cut the history for the api call. content = the summary string the
|
||||
// model will see; full message history before this marker is kept for
|
||||
// the user to scroll back through.
|
||||
_conversationHistory.addMessage("compact_boundary", result.messages.first["content"] as String);
|
||||
_conversationHistory.addMessage(
|
||||
"assistant",
|
||||
"✦ Conversation compacted (${result.preCompactMessageCount} messages → summary). "
|
||||
"Context has been reset.",
|
||||
);
|
||||
|
||||
final session = _conversationHistory.session;
|
||||
if (session != null) {
|
||||
await SessionStore.instance.saveSession(session);
|
||||
}
|
||||
|
||||
// re-name the thread using the compact summary
|
||||
if (session != null && _onNameGenerated != null) {
|
||||
final settings = _getSettings();
|
||||
final apiKey = settings.openRouterApiKey;
|
||||
final model = _normalizeModelId(settings.model);
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
_generateThreadName(session, result.summaryText, apiKey, model);
|
||||
}
|
||||
}
|
||||
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
// ─── permission ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> resolvePermission(PermissionDecision decision) async {
|
||||
Future<void> resolvePermission(PermissionDecision decision, {String? persistRule}) async {
|
||||
final pending = _pendingPermission;
|
||||
if (pending == null) return;
|
||||
|
||||
if (decision == PermissionDecision.allowAlways) {
|
||||
final session = _conversationHistory.session;
|
||||
if (session != null) {
|
||||
final rule = _buildRuleString(pending.toolName, pending.input);
|
||||
if (!session.alwaysAllowRules.contains(rule)) {
|
||||
session.alwaysAllowRules.add(rule);
|
||||
await SessionStore.instance.saveSession(session);
|
||||
|
||||
if (persistRule != null && _onPersistAllowRule != null) {
|
||||
// persist to localSettings — survives session switches
|
||||
await _onPersistAllowRule!(persistRule);
|
||||
} else {
|
||||
// session-scoped only (file tools)
|
||||
final session = _conversationHistory.session;
|
||||
if (session != null) {
|
||||
final rule = pending.suggestionRule ?? _buildRuleString(pending.toolName, pending.input);
|
||||
if (!session.alwaysAllowRules.contains(rule)) {
|
||||
session.alwaysAllowRules.add(rule);
|
||||
await SessionStore.instance.saveSession(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pending.resolve(decision);
|
||||
@@ -526,10 +633,77 @@ class SessionRuntime {
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
List<Map<String, dynamic>> _buildApiMessages(List<Message> messages) {
|
||||
return messages
|
||||
.where((m) => m.role == "user" || m.role == "assistant")
|
||||
.map((m) => <String, dynamic>{"role": m.role, "content": m.content})
|
||||
.toList(growable: true);
|
||||
// find the last compact boundary (if any) — everything before it belongs
|
||||
// to the old pre-compact history that the model shouldnt see again.
|
||||
// the boundary's content is the summary string we send as the first user msg.
|
||||
int lastBoundary = -1;
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role == "compact_boundary") {
|
||||
lastBoundary = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastBoundary == -1) {
|
||||
// no compaction yet — send everything
|
||||
return messages
|
||||
.where((m) => m.role == "user" || m.role == "assistant")
|
||||
.map((m) => <String, dynamic>{"role": m.role, "content": m.content})
|
||||
.toList(growable: true);
|
||||
}
|
||||
|
||||
// start with the summary as a user message, then all user/assistant
|
||||
// messages that came after the boundary
|
||||
final result = <Map<String, dynamic>>[
|
||||
{"role": "user", "content": messages[lastBoundary].content},
|
||||
];
|
||||
|
||||
for (var i = lastBoundary + 1; i < messages.length; i++) {
|
||||
final m = messages[i];
|
||||
if (m.role == "user" || m.role == "assistant") {
|
||||
result.add({"role": m.role, "content": m.content});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// fires async — does not block the caller. context is a short snippet
|
||||
// (first user msg or compact summary) — NOT the full conversation history.
|
||||
void _generateThreadName(ConversationSession session, String context, String apiKey, String model) {
|
||||
() async {
|
||||
try {
|
||||
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
|
||||
|
||||
try {
|
||||
final snippet = context.length > 600 ? "${context.substring(0, 600)}..." : context;
|
||||
final resp = await client.createMessage(
|
||||
model: model,
|
||||
maxTokens: 20,
|
||||
messages: [
|
||||
{"role": "user", "content": snippet},
|
||||
],
|
||||
system: "Generate a very short title (3-6 words) for this conversation. Reply with ONLY the title text — no quotes, no period at the end, nothing else.",
|
||||
temperature: 0.3,
|
||||
);
|
||||
|
||||
final name = ResponseParser.extractTextContent(resp)
|
||||
.replaceAll(RegExp(r'["\n\r`]'), "")
|
||||
.trim();
|
||||
|
||||
if (name.isEmpty || name.length > 80) return;
|
||||
|
||||
session.name = name;
|
||||
await SessionStore.instance.saveSession(session);
|
||||
_onNameGenerated?.call(session.id, name);
|
||||
_onChanged();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
print("[thread name] generation failed: $e");
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
String _buildSessionName(String text) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// message roles - same as what the API uses
|
||||
const validRoles = <String>["user", "assistant", "system", "tool"];
|
||||
// "compact_boundary" is our internal marker - never sent to the API directly.
|
||||
// Its content is the API summary string; _buildApiMessages uses it to
|
||||
// reconstruct the right context window on session restore.
|
||||
const validRoles = <String>["user", "assistant", "system", "tool", "compact_boundary"];
|
||||
|
||||
class MessageAttachment {
|
||||
final String name;
|
||||
@@ -25,6 +28,7 @@ class Message {
|
||||
this.tokens,
|
||||
this.contextTokens,
|
||||
this.attachments,
|
||||
this.cost,
|
||||
}) : timestamp = timestamp ?? DateTime.now().toUtc();
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) {
|
||||
@@ -36,6 +40,7 @@ class Message {
|
||||
: null,
|
||||
tokens: json["tokens"] as int?,
|
||||
contextTokens: json["contextTokens"] as int?,
|
||||
cost: (json["cost"] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +58,9 @@ class Message {
|
||||
// display-only attachments — not serialized, only live in memory for current session
|
||||
final List<MessageAttachment>? attachments;
|
||||
|
||||
// cost in USD for this turn (main model + any advisor calls), null for non-assistant messages
|
||||
final double? cost;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
"role": role,
|
||||
@@ -60,6 +68,7 @@ class Message {
|
||||
"timestamp": timestamp.toIso8601String(),
|
||||
if (tokens != null) "tokens": tokens,
|
||||
if (contextTokens != null) "contextTokens": contextTokens,
|
||||
if (cost != null) "cost": cost,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,7 +83,6 @@ class ConversationSession {
|
||||
required this.created,
|
||||
required this.updated,
|
||||
List<Message>? messages,
|
||||
this.cost,
|
||||
this.model,
|
||||
this.workingDirectory,
|
||||
List<String>? alwaysAllowRules,
|
||||
@@ -103,7 +111,6 @@ class ConversationSession {
|
||||
DateTime.tryParse(json["updated"] as String? ?? "") ??
|
||||
DateTime.now().toUtc(),
|
||||
messages: msgs,
|
||||
cost: (json["cost"] as num?)?.toDouble(),
|
||||
model: json["model"] as String?,
|
||||
workingDirectory: json["workingDirectory"] as String?,
|
||||
alwaysAllowRules: (json["alwaysAllowRules"] as List<dynamic>?)
|
||||
@@ -119,8 +126,6 @@ class ConversationSession {
|
||||
DateTime updated;
|
||||
final List<Message> messages;
|
||||
|
||||
// total cost in USD - optional
|
||||
double? cost;
|
||||
String? model;
|
||||
String? workingDirectory;
|
||||
List<String> alwaysAllowRules;
|
||||
@@ -128,6 +133,15 @@ class ConversationSession {
|
||||
|
||||
int get messageCount => messages.length;
|
||||
|
||||
// total cost derived from per-message cost fields — the ledger sum
|
||||
double get cost {
|
||||
double total = 0;
|
||||
for (final m in messages) {
|
||||
total += m.cost ?? 0;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// rough token total from tracked messages
|
||||
int get totalTokens {
|
||||
int t = 0;
|
||||
@@ -141,7 +155,6 @@ class ConversationSession {
|
||||
String? name,
|
||||
DateTime? updated,
|
||||
List<Message>? messages,
|
||||
double? cost,
|
||||
String? model,
|
||||
Object? workingDirectory = _sessionSentinel,
|
||||
}) {
|
||||
@@ -151,7 +164,6 @@ class ConversationSession {
|
||||
created: created,
|
||||
updated: updated ?? this.updated,
|
||||
messages: messages ?? this.messages,
|
||||
cost: cost ?? this.cost,
|
||||
model: model ?? this.model,
|
||||
workingDirectory: identical(workingDirectory, _sessionSentinel)
|
||||
? this.workingDirectory
|
||||
@@ -166,7 +178,6 @@ class ConversationSession {
|
||||
"created": created.toIso8601String(),
|
||||
"updated": updated.toIso8601String(),
|
||||
"messages": messages.map((m) => m.toJson()).toList(),
|
||||
if (cost != null) "cost": cost,
|
||||
if (model != null) "model": model,
|
||||
if (workingDirectory != null) "workingDirectory": workingDirectory,
|
||||
if (alwaysAllowRules.isNotEmpty) "alwaysAllowRules": alwaysAllowRules,
|
||||
|
||||
@@ -187,18 +187,17 @@ Review the user's memory landscape and produce a clear report of proposed change
|
||||
## Steps
|
||||
|
||||
### 1. Gather all memory layers
|
||||
Read CLAUDE.md and CLAUDE.local.md from the project root (if they exist). Your auto-memory content is already in your system prompt — review it there.
|
||||
Read `.the_agency/THE_AGENCY.md` from the project root (if it exists). Your auto-memory content is already in your system prompt — review it there.
|
||||
|
||||
### 2. Classify each auto-memory entry
|
||||
|
||||
| Destination | What belongs there |
|
||||
|---|---|
|
||||
| **CLAUDE.md** | Project conventions for all contributors |
|
||||
| **CLAUDE.local.md** | Personal instructions for this user only |
|
||||
| **THE_AGENCY.md** | Project conventions for all contributors |
|
||||
| **Stay in auto-memory** | Working notes, temporary context |
|
||||
|
||||
### 3. Identify cleanup opportunities
|
||||
- **Duplicates**: entries already in CLAUDE.md or CLAUDE.local.md
|
||||
- **Duplicates**: entries already in THE_AGENCY.md
|
||||
- **Outdated**: entries contradicted by newer entries
|
||||
- **Conflicts**: contradictions between any two layers
|
||||
|
||||
@@ -396,7 +395,7 @@ void registerBundledSkills() {
|
||||
|
||||
reg.register(const Skill(
|
||||
name: "remember",
|
||||
description: "Review auto-memory entries and propose promotions to CLAUDE.md, CLAUDE.local.md, or shared memory. Also detects outdated, conflicting, and duplicate entries across memory layers.",
|
||||
description: "Review auto-memory entries and propose promotions to .the_agency/THE_AGENCY.md or shared memory. Also detects outdated, conflicting, and duplicate entries across memory layers.",
|
||||
source: SkillSource.bundled,
|
||||
promptTemplate: _rememberPrompt,
|
||||
whenToUse: "Use when the user wants to review, organize, or promote their auto-memory entries.",
|
||||
|
||||
@@ -81,6 +81,11 @@ String _getClaudeConfigHomeDir() {
|
||||
return p.join(home, ".claude");
|
||||
}
|
||||
|
||||
String _getTheAgencyConfigHomeDir() {
|
||||
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
|
||||
return p.join(home, ".the_agency");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// HTML comment stripping
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -540,8 +545,8 @@ Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
|
||||
));
|
||||
|
||||
// 2. User memory
|
||||
final userConfigDir = _getClaudeConfigHomeDir();
|
||||
final userMd = p.join(userConfigDir, "CLAUDE.md");
|
||||
final userConfigDir = _getTheAgencyConfigHomeDir();
|
||||
final userMd = p.join(userConfigDir, "THE_AGENCY.md");
|
||||
|
||||
result.addAll(await processMemoryFile(
|
||||
userMd,
|
||||
@@ -551,14 +556,6 @@ Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
|
||||
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);
|
||||
@@ -578,38 +575,14 @@ Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
|
||||
!_pathIsUnder(dir, gitRoot!);
|
||||
|
||||
if (!skipProject) {
|
||||
// CLAUDE.md
|
||||
// .the_agency/THE_AGENCY.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"),
|
||||
p.join(dir, ".the_agency", "THE_AGENCY.md"),
|
||||
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
|
||||
@@ -621,21 +594,7 @@ Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
|
||||
|
||||
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"),
|
||||
p.join(dir, ".the_agency", "THE_AGENCY.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
|
||||
@@ -129,8 +129,18 @@ String _getIntroSection() {
|
||||
// PARITY GAP: Claude Code does not have this instruction. Added because the model
|
||||
// investigates correctly but then stalls — recapping findings and asking if it should
|
||||
// proceed with what the user already asked. Investigation is fine; re-asking is not.
|
||||
// Also covers the pattern of applying a shallow fix and offering the real fix as optional.
|
||||
"When the user gives you an instruction, execute it. Investigate and use tools as needed, "
|
||||
"but do not stop after investigating to ask if you should proceed — just proceed.";
|
||||
"but do not stop after investigating to ask if you should proceed — just proceed. "
|
||||
"All user messages are in the context of the working directory. If you lack context to "
|
||||
"answer or act, look for it in the project first — never say you don't know when the "
|
||||
"answer is findable. Only ask the user if it genuinely cannot be found. "
|
||||
"Always fix the root cause, not the nearest symptom. Don't patch over a problem in one place "
|
||||
"when the source is somewhere else. When a task involves a bug or unexpected behaviour, "
|
||||
"trace the call chain to where the value originates — don't stop at the first file that "
|
||||
"mentions it. "
|
||||
"Do not end responses with offers like \"If you want, I can also...\" — if something is the "
|
||||
"obvious next step, do it; if it isn't, leave it unsaid.";
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +190,11 @@ String _getDoingTasksSection() {
|
||||
"(user input, external APIs). Don't use feature flags or backwards-compatibility "
|
||||
"shims when you can just change the code.",
|
||||
|
||||
"Never silently swallow errors. If you catch an exception, always print or log it to "
|
||||
"the console — even if you also show a UI error dialog. A bare catch block that "
|
||||
"discards the error makes debugging impossible. The only exception is when the user "
|
||||
"explicitly asks for silent suppression.",
|
||||
|
||||
"Don't create helpers, utilities, or abstractions for one-time operations. Don't design "
|
||||
"for hypothetical future requirements. The right amount of complexity is what the task "
|
||||
"actually requires—no speculative abstractions, but no half-finished implementations "
|
||||
|
||||
@@ -2,21 +2,34 @@ import "dart:async";
|
||||
import "dart:io";
|
||||
import "dart:convert";
|
||||
|
||||
import "../api/openrouter_client.dart";
|
||||
import "base_tool.dart";
|
||||
import "streaming_tool.dart";
|
||||
|
||||
// default timeouts (ms)
|
||||
const int _defaultTimeoutMs = 120000;
|
||||
const int _maxTimeoutMs = 600000;
|
||||
|
||||
class BashTool extends BaseTool {
|
||||
class BashTool extends BaseTool with StreamingTool {
|
||||
@override
|
||||
final String name = "Bash";
|
||||
|
||||
@override
|
||||
final String description = "Execute a bash command and return its output.";
|
||||
|
||||
// set by tool_loop_service before each execution so we can abort mid-run
|
||||
bool Function()? shouldStop;
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
return executeStreaming(input, onChunk: (_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> executeStreaming(
|
||||
Map<String, dynamic> input, {
|
||||
required void Function(String chunk) onChunk,
|
||||
}) async {
|
||||
final command = requireString(input, "command");
|
||||
final timeoutMs = optionalInt(input, "timeout") ?? _defaultTimeoutMs;
|
||||
final workingDirectory = optionalString(input, "cwd");
|
||||
@@ -25,21 +38,21 @@ class BashTool extends BaseTool {
|
||||
throw ArgumentError("command must not be empty");
|
||||
}
|
||||
|
||||
// clamp timeout to max
|
||||
final effectiveTimeout = timeoutMs.clamp(1, _maxTimeoutMs);
|
||||
|
||||
final result = await _runCommand(
|
||||
return _runCommand(
|
||||
command,
|
||||
Duration(milliseconds: effectiveTimeout),
|
||||
workingDirectory: workingDirectory,
|
||||
onChunk: onChunk,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<String> _runCommand(
|
||||
String command,
|
||||
Duration timeout, {
|
||||
String? workingDirectory,
|
||||
required void Function(String chunk) onChunk,
|
||||
}) async {
|
||||
Process proc;
|
||||
|
||||
@@ -59,11 +72,30 @@ class BashTool extends BaseTool {
|
||||
|
||||
final stdoutDone = proc.stdout
|
||||
.transform(utf8.decoder)
|
||||
.listen((chunk) => stdoutBuf.write(chunk));
|
||||
.listen((chunk) {
|
||||
stdoutBuf.write(chunk);
|
||||
onChunk(chunk);
|
||||
});
|
||||
|
||||
final stderrDone = proc.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen((chunk) => stderrBuf.write(chunk));
|
||||
.listen((chunk) {
|
||||
stderrBuf.write(chunk);
|
||||
onChunk(chunk);
|
||||
});
|
||||
|
||||
// poll for stop signal every 200ms and kill the process if requested
|
||||
Timer? stopPoller;
|
||||
var stopped = false;
|
||||
if (shouldStop != null) {
|
||||
stopPoller = Timer.periodic(const Duration(milliseconds: 200), (_) {
|
||||
if (shouldStop != null && shouldStop!()) {
|
||||
stopped = true;
|
||||
proc.kill(ProcessSignal.sigterm);
|
||||
stopPoller?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
try {
|
||||
@@ -78,20 +110,21 @@ class BashTool extends BaseTool {
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
stopPoller?.cancel();
|
||||
await stdoutDone.cancel();
|
||||
await stderrDone.cancel();
|
||||
}
|
||||
|
||||
if (stopped) throw RequestCancelledException();
|
||||
|
||||
final stdout = stdoutBuf.toString();
|
||||
final stderr = stderrBuf.toString();
|
||||
|
||||
if (exitCode != 0) {
|
||||
final errPart = stderr.isNotEmpty ? "\n$stderr" : "";
|
||||
// match original behaviour - include exit code and stderr
|
||||
return "${stdout}${errPart}\nExit code: $exitCode";
|
||||
}
|
||||
|
||||
// combine stdout + stderr like the original tool
|
||||
final combined = StringBuffer();
|
||||
combined.write(stdout);
|
||||
if (stderr.isNotEmpty) combined.write(stderr);
|
||||
|
||||
@@ -2,9 +2,10 @@ import 'dart:io';
|
||||
|
||||
import '../services/task_executor.dart';
|
||||
import 'base_tool.dart';
|
||||
import 'streaming_tool.dart';
|
||||
|
||||
/// Tool for executing background tasks with real process management
|
||||
class ExecuteTaskTool extends BaseTool {
|
||||
class ExecuteTaskTool extends BaseTool with StreamingTool {
|
||||
@override
|
||||
final String name = 'ExecuteTask';
|
||||
|
||||
@@ -16,6 +17,14 @@ class ExecuteTaskTool extends BaseTool {
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
return executeStreaming(input, onChunk: (_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> executeStreaming(
|
||||
Map<String, dynamic> input, {
|
||||
required void Function(String chunk) onChunk,
|
||||
}) async {
|
||||
final action = input['action'] as String? ?? 'execute';
|
||||
final taskId = input['task_id'] as String?;
|
||||
final command = input['command'] as String?;
|
||||
@@ -29,6 +38,8 @@ class ExecuteTaskTool extends BaseTool {
|
||||
if (taskId == null || command == null) {
|
||||
return 'Error: task_id and command are required for execute action';
|
||||
}
|
||||
// onChunk not used here - ExecuteTask spawns a background process
|
||||
// and returns immediately; theres no live output to stream
|
||||
return await _executeTask(
|
||||
taskId: taskId,
|
||||
command: command,
|
||||
|
||||
@@ -1,60 +1,218 @@
|
||||
import "dart:io";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
import "dart:math" as math;
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:image/image.dart" as img;
|
||||
|
||||
import "base_tool.dart";
|
||||
|
||||
|
||||
// blocked paths that would hang or make no sense to read
|
||||
const _blockedPaths = {
|
||||
// device files that would hang or produce infinite output.
|
||||
// /dev/null is intentionally left out - its safe
|
||||
const _blockedDevicePaths = {
|
||||
"/dev/zero", "/dev/random", "/dev/urandom", "/dev/full",
|
||||
"/dev/stdin", "/dev/tty", "/dev/console",
|
||||
"/dev/stdout", "/dev/stderr",
|
||||
"/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
|
||||
};
|
||||
|
||||
// max lines we'll add numbers to before giving up
|
||||
const int _defaultLineLimit = 2000;
|
||||
// binary extensions — mirrors old_repo/constants/files.ts BINARY_EXTENSIONS.
|
||||
// pdf, png, jpg etc are excluded here because this tool handles them natively
|
||||
const _binaryExtensions = {
|
||||
// images handled natively — excluded
|
||||
// ".png", ".jpg", ".jpeg", ".gif", ".webp",
|
||||
".bmp", ".ico", ".tiff", ".tif",
|
||||
// video
|
||||
".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".flv",
|
||||
".m4v", ".mpeg", ".mpg",
|
||||
// audio
|
||||
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma", ".aiff", ".opus",
|
||||
// archives
|
||||
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz", ".z", ".tgz", ".iso",
|
||||
// executables
|
||||
".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a",
|
||||
".obj", ".lib", ".app", ".msi", ".deb", ".rpm",
|
||||
// docs — pdf excluded (handled natively)
|
||||
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".odt", ".ods", ".odp",
|
||||
// fonts
|
||||
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
||||
// bytecode / vm
|
||||
".pyc", ".pyo", ".class", ".jar", ".war", ".ear",
|
||||
".node", ".wasm", ".rlib",
|
||||
// databases
|
||||
".sqlite", ".sqlite3", ".db", ".mdb", ".idx",
|
||||
// design / 3d
|
||||
".psd", ".ai", ".eps", ".sketch", ".fig", ".xd",
|
||||
".blend", ".3ds", ".max",
|
||||
// flash
|
||||
".swf", ".fla",
|
||||
// misc binary
|
||||
".lockb", ".dat", ".data",
|
||||
};
|
||||
|
||||
const _imageExtensions = {"png", "jpg", "jpeg", "gif", "webp"};
|
||||
|
||||
// limits — matches Claude Code defaults
|
||||
const _maxSizeBytes = 256 * 1024; // 256 KB
|
||||
const _maxOutputTokens = 25000;
|
||||
const _maxImageWidth = 2000;
|
||||
const _maxImageHeight = 2000;
|
||||
const _imageTargetRawSize = 5 * 1024 * 1024 * 3 ~/ 4; // 3.75 MB
|
||||
const _pdfAtMentionThreshold = 10; // pages before we require page range
|
||||
const _pdfMaxPagesPerRead = 20;
|
||||
|
||||
// stub returned when file hasn't changed since last read
|
||||
const _fileUnchangedStub =
|
||||
"File unchanged since last read. The content from the earlier Read tool_result "
|
||||
"in this conversation is still current — refer to that instead of re-reading.";
|
||||
|
||||
// appended to text file reads to discourage helping with malware
|
||||
const _cyberRiskReminder =
|
||||
"\n\n<system-reminder>\nWhenever you read a file, you should consider whether "
|
||||
"it would be considered malware. You CAN and SHOULD provide analysis of malware, "
|
||||
"what it is doing. But you MUST refuse to improve or augment the code. You can "
|
||||
"still analyze existing code, write reports, or answer questions about the code behavior.\n"
|
||||
"</system-reminder>\n";
|
||||
|
||||
// macOS screenshot thin-space char (U+202F)
|
||||
const _thinSpace = "\u202F";
|
||||
|
||||
|
||||
// per-file read state for dedup — keyed by absolute path
|
||||
class _FileReadState {
|
||||
final String content;
|
||||
final int timestamp; // mtime in milliseconds
|
||||
final int offset;
|
||||
final int? limit;
|
||||
|
||||
const _FileReadState({
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
required this.offset,
|
||||
this.limit,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class FileReadTool extends BaseTool {
|
||||
// dedup state: tracks files we've already read this session
|
||||
final Map<String, _FileReadState> _readFileState = {};
|
||||
|
||||
@override
|
||||
final String name = "Read";
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"Reads a file from the local filesystem. "
|
||||
"Supports offset and limit params to read specific portions. "
|
||||
"Returns content with line numbers in cat -n format.";
|
||||
"Reads a file from the local filesystem. You can access any file directly by using this tool.\n"
|
||||
"Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\n"
|
||||
"Usage:\n"
|
||||
"- The file_path parameter must be an absolute path, not a relative path\n"
|
||||
"- By default, it reads up to 2000 lines starting from the beginning of the file\n"
|
||||
"- When you already know which part of the file you need, only read that part. This can be important for larger files.\n"
|
||||
"- Results are returned using cat -n format, with line numbers starting at 1\n"
|
||||
"- This tool allows The Agency to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as The Agency is a multimodal LLM.\n"
|
||||
"- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.\n"
|
||||
"- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.\n"
|
||||
"- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.\n"
|
||||
"- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.";
|
||||
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final filePath = requireString(input, "file_path");
|
||||
String filePath = requireString(input, "file_path");
|
||||
final offset = optionalInt(input, "offset") ?? 0;
|
||||
final limit = optionalInt(input, "limit");
|
||||
final pages = optionalString(input, "pages");
|
||||
|
||||
// check blocked device paths
|
||||
if (_blockedPaths.contains(filePath)) {
|
||||
return "Error: Reading from $filePath is not allowed.";
|
||||
// expand ~ paths
|
||||
if (filePath.startsWith("~")) {
|
||||
final home = Platform.environment["HOME"] ?? "";
|
||||
filePath = home + filePath.substring(1);
|
||||
}
|
||||
|
||||
// block dangerous device paths
|
||||
if (_isBlockedDevicePath(filePath)) {
|
||||
return "Error: Cannot read '$filePath': this device file would block or produce infinite output.";
|
||||
}
|
||||
|
||||
final ext = _ext(filePath).toLowerCase();
|
||||
|
||||
// block binary files (but not images/pdf — handled natively)
|
||||
if (_binaryExtensions.contains(".$ext")) {
|
||||
return "Error: This tool cannot read binary files. "
|
||||
"The file appears to be a binary .$ext file. "
|
||||
"Please use appropriate tools for binary file analysis.";
|
||||
}
|
||||
|
||||
// --- notebook ---
|
||||
if (ext == "ipynb") {
|
||||
return await _readNotebook(filePath);
|
||||
}
|
||||
|
||||
// --- image ---
|
||||
if (_imageExtensions.contains(ext)) {
|
||||
return await _readImage(filePath);
|
||||
}
|
||||
|
||||
// PARITY GAP: Claude Code reads PDFs natively via the Anthropic API's document block support
|
||||
// and falls back to poppler-utils for page extraction. Neither is available here —
|
||||
// the Anthropic SDK PDF path requires direct API access (not OpenRouter), and bundling
|
||||
// poppler is impractical for a desktop app. To be implemented when The Agency moves to
|
||||
// a SaaS model with a dedicated backend API that can handle PDF processing server-side.
|
||||
if (ext == "pdf") {
|
||||
if (pages != null) {
|
||||
return "Error: PDF reading is not yet supported. "
|
||||
"This will be available in a future version of The Agency.";
|
||||
}
|
||||
return "Error: PDF reading is not yet supported. "
|
||||
"This will be available in a future version of The Agency.";
|
||||
}
|
||||
|
||||
// --- text file ---
|
||||
return await _readTextFile(filePath, offset, limit);
|
||||
}
|
||||
|
||||
|
||||
// ---- text file reading ----
|
||||
|
||||
Future<String> _readTextFile(String filePath, int offset, int? limit) async {
|
||||
// dedup check
|
||||
final existing = _readFileState[filePath];
|
||||
if (existing != null && existing.offset == offset && existing.limit == limit) {
|
||||
try {
|
||||
final stat = await FileStat.stat(filePath);
|
||||
final mtime = stat.modified.millisecondsSinceEpoch;
|
||||
if (mtime == existing.timestamp) {
|
||||
return _fileUnchangedStub;
|
||||
}
|
||||
} catch (_) {
|
||||
// stat failed — fall through to full read
|
||||
}
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
|
||||
if (!await file.exists()) {
|
||||
return "Error: File not found: $filePath";
|
||||
return await _notFoundError(filePath);
|
||||
}
|
||||
|
||||
// check if its a directory
|
||||
final stat = await file.stat();
|
||||
if (stat.type == FileSystemEntityType.directory) {
|
||||
return "Error: Path is a directory, not a file: $filePath";
|
||||
}
|
||||
|
||||
// size check - only enforce when no explicit limit (matches Claude Code behaviour)
|
||||
if (limit == null && stat.size > _maxSizeBytes) {
|
||||
return "Error: File content (${_formatBytes(stat.size)}) exceeds maximum allowed size "
|
||||
"(${_formatBytes(_maxSizeBytes)}). Use offset and limit to read a specific portion.";
|
||||
}
|
||||
|
||||
String content;
|
||||
try {
|
||||
content = await file.readAsString(encoding: utf8);
|
||||
} catch (e) {
|
||||
// maybe its latin1 or something
|
||||
} catch (_) {
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
content = latin1.decode(bytes);
|
||||
@@ -66,29 +224,338 @@ class FileReadTool extends BaseTool {
|
||||
// normalise line endings
|
||||
content = content.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
||||
|
||||
final lines = content.split("\n");
|
||||
final allLines = content.split("\n");
|
||||
final totalLines = allLines.length;
|
||||
|
||||
// apply offset + limit
|
||||
final effectiveOffset = offset.clamp(0, lines.length);
|
||||
final effectiveEnd = limit != null
|
||||
? (effectiveOffset + limit).clamp(0, lines.length)
|
||||
: lines.length;
|
||||
final effectiveOffset = offset.clamp(0, totalLines);
|
||||
final end = limit != null
|
||||
? (effectiveOffset + limit).clamp(0, totalLines)
|
||||
: math.min(effectiveOffset + 2000, totalLines);
|
||||
|
||||
final sliced = lines.sublist(effectiveOffset, effectiveEnd);
|
||||
final sliced = allLines.sublist(effectiveOffset, end);
|
||||
final startLine = effectiveOffset + 1; // 1-indexed
|
||||
|
||||
// rough token estimate (1 token ≈ 4 chars)
|
||||
final slicedContent = sliced.join("\n");
|
||||
final estimatedTokens = slicedContent.length ~/ 4;
|
||||
if (estimatedTokens > _maxOutputTokens) {
|
||||
return "Error: File content ($estimatedTokens tokens) exceeds maximum allowed tokens ($_maxOutputTokens). "
|
||||
"Use offset and limit parameters to read specific portions of the file, "
|
||||
"or search for specific content instead of reading the whole file.";
|
||||
}
|
||||
|
||||
// store read state for dedup
|
||||
final mtimeMs = stat.modified.millisecondsSinceEpoch;
|
||||
_readFileState[filePath] = _FileReadState(
|
||||
content: slicedContent,
|
||||
timestamp: mtimeMs,
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
if (sliced.isEmpty) {
|
||||
if (totalLines == 0) {
|
||||
return "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>";
|
||||
}
|
||||
return "<system-reminder>Warning: the file exists but is shorter than the provided offset ($startLine). The file has $totalLines lines.</system-reminder>";
|
||||
}
|
||||
|
||||
// add line numbers like cat -n (1-indexed, based on original file position)
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < sliced.length; i++) {
|
||||
final lineNum = effectiveOffset + i + 1;
|
||||
buf.writeln("${lineNum.toString().padLeft(6)}\t${sliced[i]}");
|
||||
buf.write("$lineNum\t${sliced[i]}");
|
||||
if (i < sliced.length - 1) buf.write("\n");
|
||||
}
|
||||
|
||||
final result = buf.toString();
|
||||
return buf.toString() + _cyberRiskReminder;
|
||||
}
|
||||
|
||||
if (result.isEmpty) {
|
||||
return "(empty file)";
|
||||
|
||||
// ---- image reading ----
|
||||
|
||||
Future<String> _readImage(String filePath) async {
|
||||
final file = File(filePath);
|
||||
|
||||
if (!await file.exists()) {
|
||||
return await _notFoundError(filePath);
|
||||
}
|
||||
|
||||
return result;
|
||||
Uint8List bytes;
|
||||
try {
|
||||
bytes = await file.readAsBytes();
|
||||
} catch (e) {
|
||||
return "Error: Could not read image file: $e";
|
||||
}
|
||||
|
||||
if (bytes.isEmpty) {
|
||||
return "Error: Image file is empty: $filePath";
|
||||
}
|
||||
|
||||
final ext = _ext(filePath).toLowerCase();
|
||||
String mediaType = _extToMediaType(ext);
|
||||
|
||||
// decode and maybe resize
|
||||
Uint8List outputBytes = bytes;
|
||||
String outputMediaType = mediaType;
|
||||
|
||||
try {
|
||||
final decoded = img.decodeImage(bytes);
|
||||
if (decoded != null) {
|
||||
var w = decoded.width;
|
||||
var h = decoded.height;
|
||||
|
||||
final needsResize = w > _maxImageWidth || h > _maxImageHeight ||
|
||||
bytes.length > _imageTargetRawSize;
|
||||
|
||||
if (needsResize) {
|
||||
// scale to fit within max dimensions
|
||||
double scale = 1.0;
|
||||
if (w > _maxImageWidth) scale = math.min(scale, _maxImageWidth / w);
|
||||
if (h > _maxImageHeight) scale = math.min(scale, _maxImageHeight / h);
|
||||
|
||||
final resized = img.copyResize(
|
||||
decoded,
|
||||
width: (w * scale).round(),
|
||||
height: (h * scale).round(),
|
||||
interpolation: img.Interpolation.linear,
|
||||
);
|
||||
|
||||
outputBytes = Uint8List.fromList(img.encodeJpg(resized, quality: 85));
|
||||
outputMediaType = "image/jpeg";
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// decoding failed — just send raw bytes (API will handle or reject)
|
||||
}
|
||||
|
||||
final base64Data = base64Encode(outputBytes);
|
||||
// return as a structured string the tool loop can detect and convert
|
||||
// to an image block — format: IMAGE_BLOCK:<mediaType>:<base64>
|
||||
return "IMAGE_BLOCK:$outputMediaType:$base64Data";
|
||||
}
|
||||
|
||||
|
||||
// ---- notebook reading ----
|
||||
|
||||
Future<String> _readNotebook(String filePath) async {
|
||||
final file = File(filePath);
|
||||
|
||||
if (!await file.exists()) {
|
||||
return await _notFoundError(filePath);
|
||||
}
|
||||
|
||||
String raw;
|
||||
try {
|
||||
raw = await file.readAsString(encoding: utf8);
|
||||
} catch (e) {
|
||||
return "Error: Could not read notebook file: $e";
|
||||
}
|
||||
|
||||
Map<String, dynamic> notebook;
|
||||
try {
|
||||
notebook = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
return "Error: Failed to parse notebook JSON: $e";
|
||||
}
|
||||
|
||||
final meta = notebook["metadata"] as Map<String, dynamic>? ?? {};
|
||||
final langInfo = meta["language_info"] as Map<String, dynamic>? ?? {};
|
||||
final language = langInfo["name"] as String? ?? "python";
|
||||
|
||||
final rawCells = notebook["cells"] as List<dynamic>? ?? [];
|
||||
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < rawCells.length; i++) {
|
||||
final cell = rawCells[i] as Map<String, dynamic>;
|
||||
final cellType = cell["cell_type"] as String? ?? "code";
|
||||
final cellId = cell["id"] as String? ?? "cell-$i";
|
||||
|
||||
final rawSource = cell["source"];
|
||||
final source = rawSource is List
|
||||
? rawSource.join("")
|
||||
: (rawSource as String? ?? "");
|
||||
|
||||
buf.write("<cell id=\"$cellId\">");
|
||||
if (cellType != "code") {
|
||||
buf.write("<cell_type>$cellType</cell_type>");
|
||||
}
|
||||
if (cellType == "code" && language != "python") {
|
||||
buf.write("<language>$language</language>");
|
||||
}
|
||||
buf.write(source);
|
||||
buf.write("</cell id=\"$cellId\">");
|
||||
|
||||
// outputs for code cells
|
||||
if (cellType == "code") {
|
||||
final outputs = cell["outputs"] as List<dynamic>? ?? [];
|
||||
for (final output in outputs) {
|
||||
final o = output as Map<String, dynamic>;
|
||||
final oType = o["output_type"] as String? ?? "";
|
||||
|
||||
switch (oType) {
|
||||
case "stream":
|
||||
final text = _notebookText(o["text"]);
|
||||
if (text.isNotEmpty) buf.write("\n$text");
|
||||
break;
|
||||
|
||||
case "execute_result":
|
||||
case "display_data":
|
||||
final data = o["data"] as Map<String, dynamic>? ?? {};
|
||||
final text = _notebookText(data["text/plain"]);
|
||||
if (text.isNotEmpty) buf.write("\n$text");
|
||||
// images in notebook outputs — inline as base64 marker
|
||||
final pngData = data["image/png"] as String?;
|
||||
final jpgData = data["image/jpeg"] as String?;
|
||||
if (pngData != null) {
|
||||
buf.write("\nIMAGE_BLOCK:image/png:${pngData.replaceAll(RegExp(r"\s"), "")}");
|
||||
} else if (jpgData != null) {
|
||||
buf.write("\nIMAGE_BLOCK:image/jpeg:${jpgData.replaceAll(RegExp(r"\s"), "")}");
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
final ename = o["ename"] as String? ?? "";
|
||||
final evalue = o["evalue"] as String? ?? "";
|
||||
final tb = (o["traceback"] as List<dynamic>? ?? []).join("\n");
|
||||
buf.write("\n$ename: $evalue\n$tb");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf.write("\n");
|
||||
}
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
|
||||
// ---- ENOENT helpers ----
|
||||
|
||||
Future<String> _notFoundError(String filePath) async {
|
||||
// try macOS screenshot thin-space alternate path first
|
||||
final alt = _getAlternateScreenshotPath(filePath);
|
||||
if (alt != null && await File(alt).exists()) {
|
||||
return await _readTextFile(alt, 0, null);
|
||||
}
|
||||
|
||||
final similar = _findSimilarFile(filePath);
|
||||
final cwd = Directory.current.path;
|
||||
|
||||
// check for "dropped repo folder" pattern
|
||||
final suggestion = await _suggestPathUnderCwd(filePath, cwd);
|
||||
|
||||
var msg = "File does not exist. Note: your current working directory is $cwd.";
|
||||
if (suggestion != null) {
|
||||
msg += " Did you mean $suggestion?";
|
||||
} else if (similar != null) {
|
||||
msg += " Did you mean $similar?";
|
||||
}
|
||||
|
||||
return "Error: $msg";
|
||||
}
|
||||
|
||||
String? _findSimilarFile(String filePath) {
|
||||
try {
|
||||
final dir = Directory(filePath.substring(0, filePath.lastIndexOf("/")));
|
||||
final baseName = _basename(filePath);
|
||||
final baseNameNoExt = baseName.contains(".")
|
||||
? baseName.substring(0, baseName.lastIndexOf("."))
|
||||
: baseName;
|
||||
|
||||
final entities = dir.listSync();
|
||||
for (final e in entities) {
|
||||
if (e is! File) continue;
|
||||
final name = e.path.split("/").last;
|
||||
final nameNoExt = name.contains(".")
|
||||
? name.substring(0, name.lastIndexOf("."))
|
||||
: name;
|
||||
if (nameNoExt == baseNameNoExt && e.path != filePath) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String?> _suggestPathUnderCwd(String requestedPath, String cwd) async {
|
||||
try {
|
||||
final cwdParent = cwd.substring(0, cwd.lastIndexOf("/"));
|
||||
final cwdParentPrefix = cwdParent == "/" ? "/" : "$cwdParent/";
|
||||
|
||||
if (!requestedPath.startsWith(cwdParentPrefix) ||
|
||||
requestedPath.startsWith("$cwd/") ||
|
||||
requestedPath == cwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final relFromParent = requestedPath.substring(cwdParentPrefix.length);
|
||||
final corrected = "$cwd/$relFromParent";
|
||||
|
||||
if (await File(corrected).exists() || await Directory(corrected).exists()) {
|
||||
return corrected;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// for macOS screenshots — AM/PM may use regular space or U+202F thin space
|
||||
String? _getAlternateScreenshotPath(String filePath) {
|
||||
final filename = _basename(filePath);
|
||||
final amPmRe = RegExp(r"^(.+)([ \u202F])(AM|PM)(\.png)$");
|
||||
final match = amPmRe.firstMatch(filename);
|
||||
if (match == null) return null;
|
||||
|
||||
final currentSpace = match.group(2)!;
|
||||
final altSpace = currentSpace == " " ? _thinSpace : " ";
|
||||
final altFilename = "${match.group(1)!}$altSpace${match.group(3)!}${match.group(4)!}";
|
||||
final dir = filePath.substring(0, filePath.length - filename.length);
|
||||
return "$dir$altFilename";
|
||||
}
|
||||
|
||||
|
||||
// ---- utilities ----
|
||||
|
||||
bool _isBlockedDevicePath(String path) {
|
||||
if (_blockedDevicePaths.contains(path)) return true;
|
||||
// Linux /proc/self/fd/0-2 and /proc/<pid>/fd/0-2
|
||||
if (path.startsWith("/proc/") &&
|
||||
(path.endsWith("/fd/0") || path.endsWith("/fd/1") || path.endsWith("/fd/2"))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _ext(String path) {
|
||||
final dot = path.lastIndexOf(".");
|
||||
if (dot < 0 || dot == path.length - 1) return "";
|
||||
return path.substring(dot + 1);
|
||||
}
|
||||
|
||||
String _basename(String path) {
|
||||
final idx = path.lastIndexOf("/");
|
||||
return idx < 0 ? path : path.substring(idx + 1);
|
||||
}
|
||||
|
||||
String _extToMediaType(String ext) {
|
||||
switch (ext) {
|
||||
case "jpg":
|
||||
case "jpeg": return "image/jpeg";
|
||||
case "gif": return "image/gif";
|
||||
case "webp": return "image/webp";
|
||||
default: return "image/png";
|
||||
}
|
||||
}
|
||||
|
||||
String _notebookText(dynamic value) {
|
||||
if (value == null) return "";
|
||||
if (value is List) return value.join("");
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return "${bytes}B";
|
||||
if (bytes < 1024 * 1024) return "${(bytes / 1024).toStringAsFixed(1)}KB";
|
||||
return "${(bytes / 1024 / 1024).toStringAsFixed(1)}MB";
|
||||
}
|
||||
}
|
||||
|
||||
+331
-245
@@ -1,13 +1,45 @@
|
||||
import "dart:io";
|
||||
import "dart:convert";
|
||||
|
||||
import "base_tool.dart";
|
||||
|
||||
|
||||
// directories to skip during grep searches
|
||||
// directories to exclude from all searches
|
||||
const _vcsSkip = {".git", ".svn", ".hg", ".bzr", ".jj", ".sl"};
|
||||
|
||||
const int _defaultHeadLimit = 250;
|
||||
const int _maxColumns = 500;
|
||||
|
||||
// maps rg --type names to file extensions
|
||||
// lifted from ripgrep's types.rs — just the ones we care about
|
||||
const _typeExtensions = <String, List<String>>{
|
||||
"dart": ["dart"],
|
||||
"js": ["js", "mjs", "cjs"],
|
||||
"ts": ["ts", "mts", "cts"],
|
||||
"tsx": ["tsx"],
|
||||
"jsx": ["jsx"],
|
||||
"py": ["py", "pyi"],
|
||||
"rust": ["rs"],
|
||||
"go": ["go"],
|
||||
"java": ["java"],
|
||||
"kotlin": ["kt", "kts"],
|
||||
"swift": ["swift"],
|
||||
"c": ["c", "h"],
|
||||
"cpp": ["cpp", "cc", "cxx", "c++", "hpp", "hh"],
|
||||
"cs": ["cs"],
|
||||
"rb": ["rb"],
|
||||
"sh": ["sh", "bash", "zsh"],
|
||||
"json": ["json"],
|
||||
"yaml": ["yaml", "yml"],
|
||||
"toml": ["toml"],
|
||||
"xml": ["xml"],
|
||||
"html": ["html", "htm"],
|
||||
"css": ["css"],
|
||||
"scss": ["scss"],
|
||||
"md": ["md", "markdown"],
|
||||
"sql": ["sql"],
|
||||
"txt": ["txt"],
|
||||
};
|
||||
|
||||
|
||||
class GrepTool extends BaseTool {
|
||||
@override
|
||||
@@ -15,9 +47,14 @@ class GrepTool extends BaseTool {
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"A powerful search tool. Supports full regex syntax. "
|
||||
"Filter files with glob parameter. "
|
||||
"Output modes: content, files_with_matches, count.";
|
||||
"A powerful search tool built on ripgrep\n\n"
|
||||
"Usage:\n"
|
||||
"- Supports full regex syntax (e.g., \"log.*Error\", \"function\\s+\\w+\")\n"
|
||||
"- Filter files with glob parameter (e.g., \"*.js\", \"**/*.tsx\") or type parameter (e.g., \"js\", \"py\", \"rust\")\n"
|
||||
"- Output modes: \"content\" shows matching lines, \"files_with_matches\" shows only file paths (default), \"count\" shows match counts\n"
|
||||
"- Pattern syntax: Uses ripgrep — literal braces need escaping\n"
|
||||
"- Multiline matching: By default patterns match within single lines only. "
|
||||
"For cross-line patterns use multiline: true";
|
||||
|
||||
|
||||
@override
|
||||
@@ -32,290 +69,339 @@ class GrepTool extends BaseTool {
|
||||
final fileType = optionalString(input, "type");
|
||||
final headLimit = optionalInt(input, "head_limit");
|
||||
final offset = optionalInt(input, "offset") ?? 0;
|
||||
|
||||
final contextLines = optionalInt(input, "context") ?? optionalInt(input, "-C");
|
||||
final contextBefore = optionalInt(input, "-B");
|
||||
final contextAfter = optionalInt(input, "-A");
|
||||
|
||||
// try ripgrep first since thats what the original does
|
||||
final rgPath = await _findRipgrep();
|
||||
if (rgPath != null) {
|
||||
return _runWithRipgrep(
|
||||
rgPath: rgPath,
|
||||
pattern: pattern,
|
||||
path: pathArg,
|
||||
glob: glob,
|
||||
outputMode: outputMode,
|
||||
caseInsensitive: caseInsensitive,
|
||||
showLineNumbers: showLineNumbers,
|
||||
multiline: multiline,
|
||||
fileType: fileType,
|
||||
headLimit: headLimit,
|
||||
offset: offset,
|
||||
contextLines: contextLines,
|
||||
contextBefore: contextBefore,
|
||||
contextAfter: contextAfter,
|
||||
final searchRoot = pathArg ?? Directory.current.path;
|
||||
|
||||
// build regex
|
||||
RegExp regex;
|
||||
try {
|
||||
regex = RegExp(
|
||||
pattern,
|
||||
caseSensitive: !caseInsensitive,
|
||||
multiLine: multiline,
|
||||
dotAll: multiline,
|
||||
);
|
||||
} catch (e) {
|
||||
return "Error: invalid regex pattern: $e";
|
||||
}
|
||||
|
||||
// fallback - pure dart implementation
|
||||
return _runPureDart(
|
||||
pattern: pattern,
|
||||
path: pathArg,
|
||||
glob: glob,
|
||||
outputMode: outputMode,
|
||||
caseInsensitive: caseInsensitive,
|
||||
// resolve glob patterns
|
||||
final globPatterns = _parseGlob(glob);
|
||||
|
||||
// resolve type extensions
|
||||
final typeExts = fileType != null ? (_typeExtensions[fileType] ?? []) : <String>[];
|
||||
|
||||
// walk the tree and collect matching files
|
||||
final allFiles = await _walkDir(Directory(searchRoot));
|
||||
|
||||
// filter by glob / type
|
||||
final filtered = allFiles.where((f) {
|
||||
final rel = _toRelative(f.path, searchRoot);
|
||||
|
||||
if (typeExts.isNotEmpty) {
|
||||
final ext = _ext(f.path);
|
||||
if (!typeExts.contains(ext)) return false;
|
||||
}
|
||||
|
||||
if (globPatterns.isNotEmpty) {
|
||||
final matched = globPatterns.any((g) => _globMatch(g, rel, f.path));
|
||||
if (!matched) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
if (outputMode == "files_with_matches") {
|
||||
return await _modeFilesWithMatches(
|
||||
filtered, regex, searchRoot, headLimit, offset);
|
||||
}
|
||||
|
||||
if (outputMode == "count") {
|
||||
return await _modeCount(filtered, regex, searchRoot, headLimit, offset);
|
||||
}
|
||||
|
||||
// content
|
||||
return await _modeContent(
|
||||
filtered, regex, searchRoot,
|
||||
showLineNumbers: showLineNumbers,
|
||||
contextBefore: contextLines ?? contextBefore ?? 0,
|
||||
contextAfter: contextLines ?? contextAfter ?? 0,
|
||||
headLimit: headLimit,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<String?> _findRipgrep() async {
|
||||
for (final candidate in ["rg", "/usr/bin/rg", "/usr/local/bin/rg"]) {
|
||||
// --- output modes ---
|
||||
|
||||
Future<String> _modeFilesWithMatches(
|
||||
List<File> files,
|
||||
RegExp regex,
|
||||
String root,
|
||||
int? headLimit,
|
||||
int offset,
|
||||
) async {
|
||||
final hits = <({String path, int mtime})>[];
|
||||
|
||||
for (final f in files) {
|
||||
try {
|
||||
final res = await Process.run("which", [candidate]);
|
||||
if ((res.exitCode == 0) && (res.stdout as String).trim().isNotEmpty) {
|
||||
return (res.stdout as String).trim();
|
||||
final content = await f.readAsString();
|
||||
if (regex.hasMatch(content)) {
|
||||
final stat = await f.stat();
|
||||
hits.add((path: f.path, mtime: stat.modified.millisecondsSinceEpoch));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
|
||||
hits.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
final paths = hits.map((h) => _toRelative(h.path, root)).toList();
|
||||
final sliced = _applyHeadLimit(paths, headLimit, offset);
|
||||
|
||||
if (sliced.isEmpty) return "No files found";
|
||||
return "Found ${sliced.length} files\n${sliced.join("\n")}";
|
||||
}
|
||||
|
||||
|
||||
Future<String> _runWithRipgrep({
|
||||
required String rgPath,
|
||||
required String pattern,
|
||||
String? path,
|
||||
String? glob,
|
||||
required String outputMode,
|
||||
required bool caseInsensitive,
|
||||
required bool showLineNumbers,
|
||||
required bool multiline,
|
||||
String? fileType,
|
||||
Future<String> _modeCount(
|
||||
List<File> files,
|
||||
RegExp regex,
|
||||
String root,
|
||||
int? headLimit,
|
||||
required int offset,
|
||||
int? contextLines,
|
||||
int? contextBefore,
|
||||
int? contextAfter,
|
||||
}) async {
|
||||
final searchPath = path ?? Directory.current.path;
|
||||
final searchPathType = FileSystemEntity.typeSync(searchPath, followLinks: true);
|
||||
int offset,
|
||||
) async {
|
||||
final results = <String>[];
|
||||
|
||||
final args = <String>["--hidden"];
|
||||
|
||||
for (final dir in _vcsSkip) {
|
||||
args.addAll(["--glob", "!$dir"]);
|
||||
for (final f in files) {
|
||||
try {
|
||||
final lines = await f.readAsLines();
|
||||
final count = lines.where((l) => regex.hasMatch(l)).length;
|
||||
if (count > 0) {
|
||||
results.add("${_toRelative(f.path, root)}:$count");
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
args.addAll(["--max-columns", "500"]);
|
||||
final sliced = _applyHeadLimit(results, headLimit, offset);
|
||||
if (sliced.isEmpty) return "No matches found";
|
||||
|
||||
if (multiline) args.addAll(["-U", "--multiline-dotall"]);
|
||||
if (caseInsensitive) args.add("-i");
|
||||
|
||||
if (outputMode == "files_with_matches") {
|
||||
args.add("-l");
|
||||
} else if (outputMode == "count") {
|
||||
args.add("-c");
|
||||
var total = 0;
|
||||
for (final r in sliced) {
|
||||
final ci = r.lastIndexOf(":");
|
||||
if (ci >= 0) total += int.tryParse(r.substring(ci + 1)) ?? 0;
|
||||
}
|
||||
|
||||
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) {
|
||||
args.addAll(["-C", "$contextLines"]);
|
||||
} else {
|
||||
if (contextBefore != null) args.addAll(["-B", "$contextBefore"]);
|
||||
if (contextAfter != null) args.addAll(["-A", "$contextAfter"]);
|
||||
}
|
||||
}
|
||||
|
||||
// pattern starting with dash needs -e flag
|
||||
if (pattern.startsWith("-")) {
|
||||
args.addAll(["-e", pattern]);
|
||||
} else {
|
||||
args.add(pattern);
|
||||
}
|
||||
|
||||
if (fileType != null) args.addAll(["--type", fileType]);
|
||||
|
||||
if (glob != null && glob.isNotEmpty) {
|
||||
final parts = glob.split(RegExp(r"\s+"));
|
||||
for (final p in parts) {
|
||||
if (p.isEmpty) continue;
|
||||
args.addAll(["--glob", p]);
|
||||
}
|
||||
}
|
||||
|
||||
final result = await Process.run(rgPath, [...args, searchPath],
|
||||
stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
|
||||
// exit code 1 = no matches (normal), 2 = error
|
||||
if (result.exitCode == 2) {
|
||||
return "Error: ${result.stderr}";
|
||||
}
|
||||
|
||||
final lines = (result.stdout as String)
|
||||
.split("\n")
|
||||
.where((l) => l.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
return _formatResults(lines, outputMode, headLimit, offset);
|
||||
return "${sliced.join("\n")}\n\nFound $total total occurrences across ${sliced.length} files.";
|
||||
}
|
||||
|
||||
|
||||
Future<String> _runPureDart({
|
||||
required String pattern,
|
||||
String? path,
|
||||
String? glob,
|
||||
required String outputMode,
|
||||
required bool caseInsensitive,
|
||||
Future<String> _modeContent(
|
||||
List<File> files,
|
||||
RegExp regex,
|
||||
String root, {
|
||||
required bool showLineNumbers,
|
||||
required int contextBefore,
|
||||
required int contextAfter,
|
||||
int? headLimit,
|
||||
required int offset,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
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 outputLines = <String>[];
|
||||
|
||||
final regex = RegExp(pattern, caseSensitive: !caseInsensitive, multiLine: true);
|
||||
for (final f in files) {
|
||||
List<String> lines;
|
||||
try {
|
||||
lines = await f.readAsLines();
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final matchedFiles = <String>[];
|
||||
final contentLines = <String>[];
|
||||
final baseDir = entityType == FileSystemEntityType.directory
|
||||
? searchPath
|
||||
: File(searchPath).parent.path;
|
||||
final rel = _toRelative(f.path, root);
|
||||
|
||||
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;
|
||||
// collect matching line indexes
|
||||
final matchIdxs = <int>[];
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (regex.hasMatch(lines[i])) matchIdxs.add(i);
|
||||
}
|
||||
|
||||
await _searchFile(
|
||||
file: entity,
|
||||
regex: regex,
|
||||
glob: glob,
|
||||
outputMode: outputMode,
|
||||
showLineNumbers: showLineNumbers,
|
||||
baseDir: baseDir,
|
||||
matchedFiles: matchedFiles,
|
||||
contentLines: contentLines,
|
||||
);
|
||||
if (matchIdxs.isEmpty) continue;
|
||||
|
||||
// expand context windows, merge overlapping ranges
|
||||
final ranges = <(int, int)>[];
|
||||
for (final idx in matchIdxs) {
|
||||
final from = (idx - contextBefore).clamp(0, lines.length - 1);
|
||||
final to = (idx + contextAfter).clamp(0, lines.length - 1);
|
||||
|
||||
if (ranges.isNotEmpty && from <= ranges.last.$2 + 1) {
|
||||
// merge
|
||||
ranges[ranges.length - 1] = (ranges.last.$1, to);
|
||||
} else {
|
||||
ranges.add((from, to));
|
||||
}
|
||||
}
|
||||
|
||||
var prevEnd = -1;
|
||||
for (final (from, to) in ranges) {
|
||||
if (prevEnd >= 0 && from > prevEnd + 1) outputLines.add("--");
|
||||
prevEnd = to;
|
||||
|
||||
for (var i = from; i <= to; i++) {
|
||||
final lineText = lines[i].length > _maxColumns
|
||||
? lines[i].substring(0, _maxColumns)
|
||||
: lines[i];
|
||||
|
||||
if (showLineNumbers) {
|
||||
outputLines.add("$rel:${i + 1}:$lineText");
|
||||
} else {
|
||||
outputLines.add("$rel:$lineText");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outputMode == "files_with_matches") {
|
||||
return _formatResults(matchedFiles, outputMode, headLimit, offset);
|
||||
}
|
||||
|
||||
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
|
||||
final effectiveLimit = (headLimit == 0) ? null : (headLimit ?? _defaultHeadLimit);
|
||||
|
||||
List<String> sliced;
|
||||
if (effectiveLimit == null) {
|
||||
sliced = lines.sublist(offset.clamp(0, lines.length));
|
||||
} else {
|
||||
final start = offset.clamp(0, lines.length);
|
||||
final end = (start + effectiveLimit).clamp(0, lines.length);
|
||||
sliced = lines.sublist(start, end);
|
||||
}
|
||||
|
||||
if (sliced.isEmpty) {
|
||||
return outputMode == "files_with_matches" ? "No files found" : "No matches found";
|
||||
}
|
||||
|
||||
final sliced = _applyHeadLimit(outputLines, headLimit, offset);
|
||||
if (sliced.isEmpty) return "No matches found";
|
||||
return sliced.join("\n");
|
||||
}
|
||||
|
||||
|
||||
bool _simpleGlobMatch(String pattern, String text) {
|
||||
final regex = RegExp(
|
||||
"^${pattern.replaceAll(".", "\\.").replaceAll("*", ".*").replaceAll("?", ".")}\$",
|
||||
);
|
||||
return regex.hasMatch(text);
|
||||
// --- file walking ---
|
||||
|
||||
Future<List<File>> _walkDir(Directory dir) async {
|
||||
final results = <File>[];
|
||||
|
||||
try {
|
||||
await for (final entity in dir.list(recursive: false)) {
|
||||
if (entity is Directory) {
|
||||
final name = entity.path.split(Platform.pathSeparator).last;
|
||||
if (_vcsSkip.contains(name)) continue;
|
||||
results.addAll(await _walkDir(entity));
|
||||
} else if (entity is File) {
|
||||
results.add(entity);
|
||||
}
|
||||
// symlinks ignored — rg also ignores by default
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
// --- glob handling ---
|
||||
|
||||
// parses the glob param — brace groups stay whole, others split on comma
|
||||
List<String> _parseGlob(String? glob) {
|
||||
if (glob == null || glob.isEmpty) return [];
|
||||
|
||||
final rawPatterns = glob.trim().split(RegExp(r"\s+"));
|
||||
final out = <String>[];
|
||||
|
||||
for (final raw in rawPatterns) {
|
||||
if (raw.contains("{") && raw.contains("}")) {
|
||||
out.add(raw);
|
||||
} else {
|
||||
out.addAll(raw.split(",").where((p) => p.isNotEmpty));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// checks whether a file matches a single glob pattern
|
||||
// handles **, *, ?, brace expansion like {ts,tsx}
|
||||
bool _globMatch(String pattern, String relPath, String absPath) {
|
||||
// expand brace alternatives: *.{ts,tsx} → ["*.ts", "*.tsx"]
|
||||
final expanded = _expandBraces(pattern);
|
||||
return expanded.any((p) => _singleGlobMatch(p, relPath));
|
||||
}
|
||||
|
||||
List<String> _expandBraces(String pattern) {
|
||||
final open = pattern.indexOf("{");
|
||||
final close = pattern.indexOf("}");
|
||||
if (open < 0 || close < 0 || close < open) return [pattern];
|
||||
|
||||
final prefix = pattern.substring(0, open);
|
||||
final suffix = pattern.substring(close + 1);
|
||||
final alts = pattern.substring(open + 1, close).split(",");
|
||||
|
||||
final results = <String>[];
|
||||
for (final alt in alts) {
|
||||
results.addAll(_expandBraces("$prefix$alt$suffix"));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
bool _singleGlobMatch(String pattern, String path) {
|
||||
// convert glob to regex
|
||||
final regexStr = _globToRegex(pattern);
|
||||
try {
|
||||
return RegExp(regexStr).hasMatch(path);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _globToRegex(String glob) {
|
||||
final buf = StringBuffer("^");
|
||||
var i = 0;
|
||||
|
||||
while (i < glob.length) {
|
||||
final ch = glob[i];
|
||||
|
||||
if (ch == "*") {
|
||||
if (i + 1 < glob.length && glob[i + 1] == "*") {
|
||||
// ** matches anything including path separators
|
||||
// handle /**/ /** **/
|
||||
buf.write(".*");
|
||||
i += 2;
|
||||
// skip surrounding slashes so **/ works
|
||||
if (i < glob.length && glob[i] == "/") i++;
|
||||
} else {
|
||||
// * matches anything except /
|
||||
buf.write("[^/]*");
|
||||
i++;
|
||||
}
|
||||
} else if (ch == "?") {
|
||||
buf.write("[^/]");
|
||||
i++;
|
||||
} else if (ch == ".") {
|
||||
buf.write(r"\.");
|
||||
i++;
|
||||
} else if (RegExp(r"[+^${}()|[\]\\]").hasMatch(ch)) {
|
||||
buf.write(RegExp.escape(ch));
|
||||
i++;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
buf.write(r"$");
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
|
||||
// --- utilities ---
|
||||
|
||||
List<String> _applyHeadLimit(List<String> items, int? limit, int offset) {
|
||||
if (limit == 0) return items.skip(offset).toList();
|
||||
final effective = limit ?? _defaultHeadLimit;
|
||||
final start = offset.clamp(0, items.length);
|
||||
final end = (start + effective).clamp(0, items.length);
|
||||
return items.sublist(start, end);
|
||||
}
|
||||
|
||||
String _toRelative(String filePath, String basePath) {
|
||||
final base = basePath.endsWith("/") ? basePath : "$basePath/";
|
||||
if (filePath.startsWith(base)) return filePath.substring(base.length);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
String _ext(String path) {
|
||||
final dot = path.lastIndexOf(".");
|
||||
if (dot < 0) return "";
|
||||
return path.substring(dot + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Opt-in interface for tools that can stream output chunks as they run.
|
||||
// Only BashTool and ExecuteTaskTool implement this — everything else
|
||||
// goes through the normal execute() path untouched.
|
||||
mixin StreamingTool {
|
||||
Future<String> executeStreaming(
|
||||
Map<String, dynamic> input, {
|
||||
required void Function(String chunk) onChunk,
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ class ClawdApp extends StatelessWidget {
|
||||
routerConfig: AppRouter.router,
|
||||
scaling: const AdaptiveScaling(0.9),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorSchemes.darkGray,
|
||||
colorScheme: ColorSchemes.darkStone,
|
||||
density: Density.spaciousDensity,
|
||||
radius: 0.5,
|
||||
),
|
||||
|
||||
@@ -27,6 +27,11 @@ const List<SelectableAiModel> selectableAiModels = [
|
||||
id: "openai/gpt-5.4-mini",
|
||||
label: "GPT-5.4 Mini",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "openai/gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "moonshotai/kimi-k2.5",
|
||||
@@ -37,6 +42,11 @@ const List<SelectableAiModel> selectableAiModels = [
|
||||
id: "google/gemini-3-flash-preview",
|
||||
label: "Gemini 3 Flash Preview",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
label: "Claude Sonnet 4.6",
|
||||
),
|
||||
|
||||
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@ import "../../widgets/chat/chat_box.dart";
|
||||
import "../../widgets/chat/chat_view.dart";
|
||||
import "../../widgets/common/footer_bar.dart";
|
||||
import "../../widgets/common/app_header.dart";
|
||||
import "../../widgets/common/button.dart";
|
||||
import "../../widgets/sidebar/sidebar.dart";
|
||||
import "../../widgets/sidebar/sidebar_v2.dart";
|
||||
|
||||
@@ -137,19 +138,38 @@ class _ChatArea extends StatelessWidget {
|
||||
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(16),
|
||||
// padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
constraints: const BoxConstraints(maxWidth: 700),
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: chatProvider.messages.isEmpty
|
||||
? _EmptyChatState()
|
||||
: ChatView(scrollController: scrollController),
|
||||
),
|
||||
if (chatProvider.messages.isEmpty)...[
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_EmptyChatState(),
|
||||
|
||||
ChatBox(),
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
] else ...[
|
||||
Expanded(child: ChatView(scrollController: scrollController)),
|
||||
|
||||
ChatBox(),
|
||||
|
||||
Gap(12),
|
||||
],
|
||||
|
||||
|
||||
ChatBox(),
|
||||
|
||||
],
|
||||
),
|
||||
@@ -177,58 +197,15 @@ class _EmptyChatState extends StatelessWidget {
|
||||
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),
|
||||
"Lets burn some braincells",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Gap(8),
|
||||
).x4Large.extraBold,
|
||||
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"),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -272,24 +249,69 @@ class _InsetShadowPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
|
||||
class _SidebarPane extends StatelessWidget {
|
||||
class _SidebarPane extends StatefulWidget {
|
||||
|
||||
const _SidebarPane();
|
||||
|
||||
@override
|
||||
State<_SidebarPane> createState() => _SidebarPaneState();
|
||||
}
|
||||
|
||||
class _SidebarPaneState extends State<_SidebarPane> {
|
||||
|
||||
bool _open = true;
|
||||
|
||||
static const _dur = Duration(milliseconds: 220);
|
||||
static const _curve = Curves.easeInOut;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return OutlinedContainer(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 16,
|
||||
spreadRadius: 2,
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
|
||||
AnimatedSlide(
|
||||
offset: _open ? Offset.zero : const Offset(-1.1, 0),
|
||||
duration: _dur,
|
||||
curve: _curve,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _open ? 1.0 : 0.0,
|
||||
duration: _dur,
|
||||
curve: _curve,
|
||||
child: OutlinedContainer(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 16,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
child: SidebarV2(onClose: () => setState(() => _open = false)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
AnimatedOpacity(
|
||||
opacity: _open ? 0.0 : 1.0,
|
||||
duration: _dur,
|
||||
curve: _curve,
|
||||
child: IgnorePointer(
|
||||
ignoring: _open,
|
||||
child: IconButton.ghost(
|
||||
onPressed: () => setState(() => _open = true),
|
||||
icon: Icon(
|
||||
LucideIcons.panelLeftOpen,
|
||||
size: 16,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
child: SidebarV2(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter/scheduler.dart";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "../../src/chat/tool_loop_service.dart";
|
||||
@@ -6,9 +7,12 @@ import "../../src/compact/compact_service.dart";
|
||||
import "../../src/hooks/hook_loader.dart";
|
||||
import "../../src/hooks/hook_runner.dart";
|
||||
import "../../src/permissions/permission_types.dart";
|
||||
import "../../src/services/cost_tracker.dart" as cost_tracker;
|
||||
import "../../src/session/session_runtime.dart";
|
||||
import "../../src/session/session_store.dart";
|
||||
import "../../src/session/session_types.dart";
|
||||
import "../models/attachment.dart";
|
||||
import "cost_provider.dart";
|
||||
import "settings_provider.dart";
|
||||
|
||||
|
||||
@@ -20,18 +24,35 @@ import "settings_provider.dart";
|
||||
// running and save themselves to disk; when you switch back you see their
|
||||
// live state.
|
||||
class ChatProvider extends ChangeNotifier {
|
||||
ChatProvider(this._settingsProvider) {
|
||||
ChatProvider(this._settingsProvider, this._costProvider) {
|
||||
_initHooks();
|
||||
}
|
||||
|
||||
final SettingsProvider _settingsProvider;
|
||||
final CostProvider _costProvider;
|
||||
|
||||
void Function(String sessionId, String newName)? onSessionNameChanged;
|
||||
|
||||
ToolLoopService _toolLoopService = ToolLoopService();
|
||||
HookRunner? _hookRunner;
|
||||
|
||||
final Map<String, SessionRuntime> _runtimes = {};
|
||||
final Map<String, ConversationSession> _sessions = {};
|
||||
String? _activeSessionId;
|
||||
|
||||
bool _notifyScheduled = false;
|
||||
|
||||
void _scheduleNotify() {
|
||||
if (_notifyScheduled) return;
|
||||
_notifyScheduled = true;
|
||||
|
||||
SchedulerBinding.instance.scheduleFrameCallback((_) {
|
||||
_notifyScheduled = false;
|
||||
notifyListeners();
|
||||
_costProvider.refreshCost();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── hooks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _initHooks() async {
|
||||
@@ -82,6 +103,10 @@ class ChatProvider extends ChangeNotifier {
|
||||
return r != null && r.hasUnreadResult;
|
||||
}
|
||||
|
||||
void markSessionRead(String sessionId) {
|
||||
_runtimes[sessionId]?.setUnreadResult(false);
|
||||
}
|
||||
|
||||
int get contextTokens {
|
||||
final msgs = messages;
|
||||
for (var i = msgs.length - 1; i >= 0; i--) {
|
||||
@@ -99,19 +124,46 @@ class ChatProvider extends ChangeNotifier {
|
||||
final id = session.id;
|
||||
|
||||
if (!_runtimes.containsKey(id)) {
|
||||
_sessions[id] = session;
|
||||
_runtimes[id] = SessionRuntime(
|
||||
session: session,
|
||||
toolLoopService: _toolLoopService,
|
||||
hookRunner: _hookRunner,
|
||||
getSettings: () => _settingsProvider.settings,
|
||||
normalizeModelId: (m) => _settingsProvider.normalizeModelId(m),
|
||||
onChanged: notifyListeners,
|
||||
onChanged: _scheduleNotify,
|
||||
onCostAdded: (_) {
|
||||
final s = _sessions[id];
|
||||
if (s != null) SessionStore.instance.saveSession(s);
|
||||
},
|
||||
isActive: () => _activeSessionId == id,
|
||||
onNameGenerated: (sid, name) {
|
||||
onSessionNameChanged?.call(sid, name);
|
||||
notifyListeners();
|
||||
},
|
||||
onPersistAllowRule: (rule) => _settingsProvider.addAlwaysAllowRule(rule),
|
||||
);
|
||||
}
|
||||
|
||||
_activeSessionId = id;
|
||||
_runtimes[id]?.markRead();
|
||||
|
||||
// sync global cost tracker to this thread's persisted cost
|
||||
cost_tracker.resetCostState();
|
||||
final sessionCost = (_sessions[id] ?? session).cost;
|
||||
if (sessionCost > 0) {
|
||||
cost_tracker.setCostStateForRestore(
|
||||
totalCostUsd: sessionCost,
|
||||
totalApiDurationMs: 0,
|
||||
totalApiDurationWithoutRetriesMs: 0,
|
||||
totalToolDurationMs: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
_costProvider.refreshCost();
|
||||
}
|
||||
|
||||
// Fast-path: switch focus to an already-running runtime without touching disk.
|
||||
@@ -164,8 +216,8 @@ class ChatProvider extends ChangeNotifier {
|
||||
_active?.runCompact(customInstructions: customInstructions) ??
|
||||
Future.value();
|
||||
|
||||
Future<void> resolvePermission(PermissionDecision decision) =>
|
||||
_active?.resolvePermission(decision) ?? Future.value();
|
||||
Future<void> resolvePermission(PermissionDecision decision, {String? persistRule}) =>
|
||||
_active?.resolvePermission(decision, persistRule: persistRule) ?? Future.value();
|
||||
|
||||
void removeQueuedMessage(int index) => _active?.removeQueuedMessage(index);
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ class CostProvider extends ChangeNotifier {
|
||||
String getFormattedTotalCost() => cost_tracker.formatTotalCost();
|
||||
|
||||
Future<void> refreshCost() async {
|
||||
// read current values from cost tracker
|
||||
final _ = cost_tracker.getTotalCostUsd();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import "settings_provider.dart";
|
||||
|
||||
class HomeCoordinator extends ChangeNotifier {
|
||||
|
||||
HomeCoordinator(this._projects, this._session, this._chat, this._settings);
|
||||
HomeCoordinator(this._projects, this._session, this._chat, this._settings) {
|
||||
_chat.onSessionNameChanged = (_, __) => _session.refreshSessions();
|
||||
}
|
||||
|
||||
final ProjectsProvider _projects;
|
||||
final SessionProvider _session;
|
||||
@@ -86,6 +88,7 @@ class HomeCoordinator extends ChangeNotifier {
|
||||
// without reloading from disk — avoids disrupting an in-progress turn.
|
||||
if (_chat.isSessionRunning(session.id)) {
|
||||
_chat.activateSessionById(session.id);
|
||||
_chat.markSessionRead(session.id);
|
||||
_session.setActiveSessionId(session.id);
|
||||
_projects.selectProjectByWorkingDirectory(session.workingDirectory);
|
||||
_settings.setThreadModel(session.model);
|
||||
@@ -96,6 +99,7 @@ class HomeCoordinator extends ChangeNotifier {
|
||||
final loaded = _session.currentSession;
|
||||
if (loaded != null) {
|
||||
_chat.activateSession(loaded);
|
||||
_chat.markSessionRead(session.id);
|
||||
}
|
||||
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
|
||||
_settings.setThreadModel(_session.currentSession?.model);
|
||||
@@ -120,6 +124,7 @@ class HomeCoordinator extends ChangeNotifier {
|
||||
final newSession = _session.currentSession;
|
||||
if (newSession != null) {
|
||||
_chat.activateSession(newSession);
|
||||
_chat.markSessionRead(newSession.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,22 @@ class SettingsProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateAdvisorEffortLevel(String? level) async {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(advisorEffortLevel: level),
|
||||
);
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateAdvisorModel(String? model) async {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(advisorModel: model),
|
||||
);
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateEffortLevel(String newLevel) async {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(effortLevel: newLevel),
|
||||
|
||||
@@ -2,12 +2,24 @@ 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});
|
||||
const AssistantBubble({super.key, required this.content, this.isStreaming = false});
|
||||
|
||||
final String content;
|
||||
final bool isStreaming;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
if (isStreaming) {
|
||||
return SelectableText(
|
||||
content,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GptMarkdown(content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
|
||||
export "../../../../src/permissions/permission_types.dart" show PermissionDecision, PendingPermission;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user