From 728c0ffe81df612d0092df826aa2a225ddcaa2f9 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Tue, 28 Apr 2026 19:00:27 +0100 Subject: [PATCH] Add command files and enhance session management features --- CLAUDE.md | 1 + backend/.gitkeep | 0 backend/bin/server.dart | 48 + backend/pubspec.lock | 141 + backend/pubspec.yaml | 15 + docs/legacy/AUDIT_COMPLETION_REPORT.md | 254 -- docs/legacy/CHANGES_SUMMARY.txt | 340 -- docs/legacy/DOCUMENTATION_INDEX.md | 72 - docs/legacy/FINAL_PARITY_AUDIT.md | 434 -- docs/legacy/FULL_PARITY_ROADMAP.md | 371 -- docs/legacy/IMPLEMENTATION_SUMMARY.md | 78 - docs/legacy/MIGRATION_COMPLETION_REPORT.md | 404 -- docs/legacy/MIGRATION_STATUS.md | 457 -- docs/legacy/PARITY_STATUS.md | 326 -- docs/legacy/QUICK_START_REPL.md | 234 - docs/legacy/README_MIGRATION.md | 247 -- docs/legacy/SCROLLING_FIX_SUMMARY.md | 59 - .../xcshareddata/swiftpm/Package.resolved | 59 + .../xcshareddata/swiftpm/Package.resolved | 59 + lib/main.dart | 1 + lib/src/api/openrouter_client.dart | 14 + lib/src/app.dart | 3914 +---------------- lib/src/chat/advisor_service.dart | 146 +- lib/src/chat/tool_loop_service.dart | 151 +- lib/src/commands/_shared.dart | 130 + lib/src/commands/add_dir.dart | 43 + lib/src/commands/advisor.dart | 40 + lib/src/commands/agents.dart | 14 + lib/src/commands/attach.dart | 33 + lib/src/commands/branch.dart | 41 + lib/src/commands/brief.dart | 8 + lib/src/commands/btw.dart | 27 + lib/src/commands/bughunter.dart | 17 + lib/src/commands/chrome.dart | 17 + lib/src/commands/clear.dart | 6 + lib/src/commands/color.dart | 29 + lib/src/commands/commit.dart | 37 + lib/src/commands/commit_push_pr.dart | 46 + lib/src/commands/compact.dart | 16 + lib/src/commands/config.dart | 36 + lib/src/commands/context.dart | 20 + lib/src/commands/copy.dart | 33 + lib/src/commands/cost.dart | 7 + lib/src/commands/desktop.dart | 19 + lib/src/commands/diff.dart | 32 + lib/src/commands/doctor.dart | 72 + lib/src/commands/effort.dart | 84 + lib/src/commands/env.dart | 32 + lib/src/commands/exit.dart | 5 + lib/src/commands/export.dart | 34 + lib/src/commands/fast.dart | 31 + lib/src/commands/feedback.dart | 19 + lib/src/commands/files.dart | 9 + lib/src/commands/help.dart | 73 + lib/src/commands/hooks.dart | 36 + lib/src/commands/ide.dart | 35 + lib/src/commands/init.dart | 155 + lib/src/commands/init_verifiers.dart | 21 + lib/src/commands/install_github_app.dart | 39 + lib/src/commands/keybindings.dart | 55 + lib/src/commands/kill.dart | 28 + lib/src/commands/lint.dart | 59 + lib/src/commands/login.dart | 9 + lib/src/commands/logout.dart | 6 + lib/src/commands/logs.dart | 29 + lib/src/commands/mcp.dart | 111 + lib/src/commands/memory.dart | 61 + lib/src/commands/mobile.dart | 22 + lib/src/commands/model.dart | 66 + lib/src/commands/output_style.dart | 8 + lib/src/commands/permissions.dart | 235 + lib/src/commands/plan.dart | 36 + lib/src/commands/plugin.dart | 72 + lib/src/commands/pr_comments.dart | 28 + lib/src/commands/privacy_settings.dart | 27 + lib/src/commands/ps.dart | 27 + lib/src/commands/release_notes.dart | 15 + lib/src/commands/rename.dart | 26 + lib/src/commands/resume.dart | 52 + lib/src/commands/review.dart | 25 + lib/src/commands/rewind.dart | 13 + lib/src/commands/security_review.dart | 43 + lib/src/commands/session.dart | 11 + lib/src/commands/skills.dart | 15 + lib/src/commands/stats.dart | 31 + lib/src/commands/status.dart | 54 + lib/src/commands/statusline.dart | 31 + lib/src/commands/stickers.dart | 32 + lib/src/commands/tag.dart | 42 + lib/src/commands/tasks.dart | 9 + lib/src/commands/terminal_setup.dart | 52 + lib/src/commands/theme.dart | 22 + lib/src/commands/tools.dart | 18 + lib/src/commands/upgrade.dart | 22 + lib/src/commands/usage.dart | 22 + lib/src/commands/version.dart | 7 + lib/src/commands/vim.dart | 16 + lib/src/commands/voice.dart | 11 + lib/src/local_state.dart | 17 +- lib/src/session/conversation_history.dart | 22 +- lib/src/session/session_runtime.dart | 230 +- lib/src/session/session_types.dart | 27 +- lib/src/skills/skill_registry.dart | 9 +- lib/src/system_prompt/claude_md_loader.dart | 61 +- .../system_prompt/system_prompt_builder.dart | 17 +- lib/src/tools/bash_tool.dart | 49 +- lib/src/tools/execute_task_tool.dart | 13 +- lib/src/tools/file_read_tool.dart | 525 ++- lib/src/tools/grep_tool.dart | 576 +-- lib/src/tools/streaming_tool.dart | 9 + lib/ui/app.dart | 2 +- lib/ui/constants.dart | 10 + lib/ui/pages/home_screen/page.dart | 146 +- lib/ui/providers/chat_provider.dart | 60 +- lib/ui/providers/cost_provider.dart | 2 - lib/ui/providers/home_coordinator.dart | 7 +- lib/ui/providers/settings_provider.dart | 16 + .../chat/bubbles/assistant_bubble.dart | 14 +- .../chat/bubbles/permission_decision.dart | 2 +- lib/ui/widgets/chat/bubbles/tool_bubble.dart | 52 +- .../chat/bubbles/tools/advisor_bubble.dart | 7 +- .../chat/bubbles/tools/bash_bubble.dart | 391 +- .../bubbles/tools/default_tool_bubble.dart | 7 +- .../chat/bubbles/tools/edit_bubble.dart | 7 +- .../chat/bubbles/tools/glob_bubble.dart | 7 +- .../chat/bubbles/tools/grep_bubble.dart | 7 +- .../chat/bubbles/tools/read_bubble.dart | 7 +- .../chat/bubbles/tools/tool_bubble_base.dart | 362 +- .../chat/bubbles/tools/web_fetch_bubble.dart | 7 +- .../chat/bubbles/tools/web_search_bubble.dart | 7 +- .../chat/bubbles/tools/write_bubble.dart | 7 +- lib/ui/widgets/chat/bubbles/user_bubble.dart | 140 +- lib/ui/widgets/chat/chat_box.dart | 326 +- lib/ui/widgets/chat/chat_view.dart | 129 +- lib/ui/widgets/chat/model_picker.dart | 1 + lib/ui/widgets/chat/model_picker_dialog.dart | 1 + lib/ui/widgets/chat/models_panel.dart | 175 + lib/ui/widgets/common/pane_dialog.dart | 156 + lib/ui/widgets/common/panel_layout.dart | 248 ++ lib/ui/widgets/common/settings_sheet.dart | 12 +- lib/ui/widgets/sidebar/account_button.dart | 52 + lib/ui/widgets/sidebar/sidebar_v2.dart | 108 +- macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Release.entitlements | 2 + pubspec.lock | 32 +- pubspec.yaml | 2 +- 146 files changed, 6854 insertions(+), 7783 deletions(-) create mode 100644 backend/.gitkeep create mode 100644 backend/bin/server.dart create mode 100644 backend/pubspec.lock create mode 100644 backend/pubspec.yaml delete mode 100644 docs/legacy/AUDIT_COMPLETION_REPORT.md delete mode 100644 docs/legacy/CHANGES_SUMMARY.txt delete mode 100644 docs/legacy/DOCUMENTATION_INDEX.md delete mode 100644 docs/legacy/FINAL_PARITY_AUDIT.md delete mode 100644 docs/legacy/FULL_PARITY_ROADMAP.md delete mode 100644 docs/legacy/IMPLEMENTATION_SUMMARY.md delete mode 100644 docs/legacy/MIGRATION_COMPLETION_REPORT.md delete mode 100644 docs/legacy/MIGRATION_STATUS.md delete mode 100644 docs/legacy/PARITY_STATUS.md delete mode 100644 docs/legacy/QUICK_START_REPL.md delete mode 100644 docs/legacy/README_MIGRATION.md delete mode 100644 docs/legacy/SCROLLING_FIX_SUMMARY.md create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 lib/src/commands/_shared.dart create mode 100644 lib/src/commands/add_dir.dart create mode 100644 lib/src/commands/advisor.dart create mode 100644 lib/src/commands/agents.dart create mode 100644 lib/src/commands/attach.dart create mode 100644 lib/src/commands/branch.dart create mode 100644 lib/src/commands/brief.dart create mode 100644 lib/src/commands/btw.dart create mode 100644 lib/src/commands/bughunter.dart create mode 100644 lib/src/commands/chrome.dart create mode 100644 lib/src/commands/clear.dart create mode 100644 lib/src/commands/color.dart create mode 100644 lib/src/commands/commit.dart create mode 100644 lib/src/commands/commit_push_pr.dart create mode 100644 lib/src/commands/compact.dart create mode 100644 lib/src/commands/config.dart create mode 100644 lib/src/commands/context.dart create mode 100644 lib/src/commands/copy.dart create mode 100644 lib/src/commands/cost.dart create mode 100644 lib/src/commands/desktop.dart create mode 100644 lib/src/commands/diff.dart create mode 100644 lib/src/commands/doctor.dart create mode 100644 lib/src/commands/effort.dart create mode 100644 lib/src/commands/env.dart create mode 100644 lib/src/commands/exit.dart create mode 100644 lib/src/commands/export.dart create mode 100644 lib/src/commands/fast.dart create mode 100644 lib/src/commands/feedback.dart create mode 100644 lib/src/commands/files.dart create mode 100644 lib/src/commands/help.dart create mode 100644 lib/src/commands/hooks.dart create mode 100644 lib/src/commands/ide.dart create mode 100644 lib/src/commands/init.dart create mode 100644 lib/src/commands/init_verifiers.dart create mode 100644 lib/src/commands/install_github_app.dart create mode 100644 lib/src/commands/keybindings.dart create mode 100644 lib/src/commands/kill.dart create mode 100644 lib/src/commands/lint.dart create mode 100644 lib/src/commands/login.dart create mode 100644 lib/src/commands/logout.dart create mode 100644 lib/src/commands/logs.dart create mode 100644 lib/src/commands/mcp.dart create mode 100644 lib/src/commands/memory.dart create mode 100644 lib/src/commands/mobile.dart create mode 100644 lib/src/commands/model.dart create mode 100644 lib/src/commands/output_style.dart create mode 100644 lib/src/commands/permissions.dart create mode 100644 lib/src/commands/plan.dart create mode 100644 lib/src/commands/plugin.dart create mode 100644 lib/src/commands/pr_comments.dart create mode 100644 lib/src/commands/privacy_settings.dart create mode 100644 lib/src/commands/ps.dart create mode 100644 lib/src/commands/release_notes.dart create mode 100644 lib/src/commands/rename.dart create mode 100644 lib/src/commands/resume.dart create mode 100644 lib/src/commands/review.dart create mode 100644 lib/src/commands/rewind.dart create mode 100644 lib/src/commands/security_review.dart create mode 100644 lib/src/commands/session.dart create mode 100644 lib/src/commands/skills.dart create mode 100644 lib/src/commands/stats.dart create mode 100644 lib/src/commands/status.dart create mode 100644 lib/src/commands/statusline.dart create mode 100644 lib/src/commands/stickers.dart create mode 100644 lib/src/commands/tag.dart create mode 100644 lib/src/commands/tasks.dart create mode 100644 lib/src/commands/terminal_setup.dart create mode 100644 lib/src/commands/theme.dart create mode 100644 lib/src/commands/tools.dart create mode 100644 lib/src/commands/upgrade.dart create mode 100644 lib/src/commands/usage.dart create mode 100644 lib/src/commands/version.dart create mode 100644 lib/src/commands/vim.dart create mode 100644 lib/src/commands/voice.dart create mode 100644 lib/src/tools/streaming_tool.dart create mode 100644 lib/ui/widgets/chat/models_panel.dart create mode 100644 lib/ui/widgets/common/pane_dialog.dart create mode 100644 lib/ui/widgets/common/panel_layout.dart create mode 100644 lib/ui/widgets/sidebar/account_button.dart diff --git a/CLAUDE.md b/CLAUDE.md index 868fadc..31376a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,4 +21,5 @@ Always assume any implementation should achieve **full parity** with Claude Code When a file in `lib/src/` strays from Claude Code behaviour, mark it with a `// PARITY GAP:` comment at the diverging code. Also list it here: - **`lib/src/chat/tool_loop_service.dart`** — Skips image resize/downsample (`maybeResizeAndDownsampleImageBlock`) and does not store pasted images to disk (`storeImages`). Claude Code does both in `processUserInput.ts`. +- **`lib/src/tools/file_read_tool.dart`** — PDF reading returns a helpful error instead of actual content. Claude Code uses the Anthropic API's native PDF support + poppler for page extraction; Dart has no equivalent without native binaries. All other gaps (images, notebooks, dedup, binary detection, token limits, ENOENT suggestions, cyber risk reminder, macOS thin-space paths) are implemented. - **`lib/ui/providers/chat_provider.dart`** (UI layer) — Images sent as OpenAI-format `image_url` data URLs (OpenRouter requirement) instead of Anthropic-format base64 blocks. Uses a flat attachment list instead of Claude Code's `PastedContent` ID-ref system. Non-image files embedded as plain text rather than document blocks. See `handlePromptSubmit.ts` + `processUserInput.ts`. \ No newline at end of file diff --git a/backend/.gitkeep b/backend/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/bin/server.dart b/backend/bin/server.dart new file mode 100644 index 0000000..ce3c55b --- /dev/null +++ b/backend/bin/server.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_router/shelf_router.dart'; + +Future main(List args) async { + final apiKey = Platform.environment['OPENROUTER_API_KEY']; + final baseUrl = Platform.environment['OPENROUTER_BASE_URL'] ?? 'https://openrouter.ai/api/v1'; + + final router = Router() + ..post('/v1/chat/completions', (Request request) async { + if (apiKey == null || apiKey.isEmpty) { + return Response(500, body: 'OPENROUTER_API_KEY is not set'); + } + + final body = await request.readAsString(); + final upstream = await http.post( + Uri.parse('$baseUrl/chat/completions'), + headers: { + 'Authorization': 'Bearer $apiKey', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'HTTP-Referer': 'http://localhost:8080', + 'X-Title': 'clawd_code backend', + }, + body: body, + ); + + return Response( + upstream.statusCode, + body: upstream.body, + headers: { + 'content-type': upstream.headers['content-type'] ?? 'application/json', + }, + ); + }) + ..post('/v1/models', (Request request) => Response(405, body: 'Method not allowed')) + ..all('/', (Request request, String ignored) => Response.notFound('Not found')); + + final handler = Pipeline() + .addMiddleware(logRequests()) + .addHandler(router.call); + + final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080); + stdout.writeln('Serving at http://${server.address.host}:${server.port}'); +} diff --git a/backend/pubspec.lock b/backend/pubspec.lock new file mode 100644 index 0000000..d2dba91 --- /dev/null +++ b/backend/pubspec.lock @@ -0,0 +1,141 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.8.1 <4.0.0" diff --git a/backend/pubspec.yaml b/backend/pubspec.yaml new file mode 100644 index 0000000..d25e8bf --- /dev/null +++ b/backend/pubspec.yaml @@ -0,0 +1,15 @@ +name: backend +description: Backend server for clawd_code. +publish_to: none +version: 0.1.0 + +environment: + sdk: ^3.8.1 + +dependencies: + shelf: ^1.4.2 + shelf_router: ^1.1.4 + http: ^1.2.2 + +dev_dependencies: + lints: ^5.0.0 diff --git a/docs/legacy/AUDIT_COMPLETION_REPORT.md b/docs/legacy/AUDIT_COMPLETION_REPORT.md deleted file mode 100644 index 66423d3..0000000 --- a/docs/legacy/AUDIT_COMPLETION_REPORT.md +++ /dev/null @@ -1,254 +0,0 @@ -# Final Corrective Parity Pass — Completion Report - -**Date:** 2026-04-04 -**Task:** Conduct fresh, accurate parity audit. Fix reports. Remove contradictions. Make corrections. -**Status:** ✅ COMPLETE - ---- - -## What This Pass Did - -### 1. Comprehensive Fresh Audit ✅ - -**Method:** Line-by-line code inspection (not relying on prior reports) - -**Examined:** -- `lib/src/tools/` — All 12 tool implementations -- `lib/src/services/` — API client, analytics, usage tracking -- `lib/src/api/` — Message types, OpenRouter client -- `lib/src/constants.dart` — Vendor-neutral infrastructure -- `lib/src/permissions/` — Permission system -- Core architectural files - -**Key discovery:** Prior reports vastly overstated parity. Several claimed "implementations" are actually: -- Stubbed tools that return mock data (Task, Skill, MCP, Agent) -- Unfinished wiring (analytics exists but non-functional) -- Missing core features (REPL, model integration, tool loop execution) - -### 2. Honest Parity Assessment ✅ - -**Prior claims:** "Full parity," "25-30% parity," "Most features work" -**Actual reality:** - -| Category | Real Parity | Notes | -|----------|-------------|-------| -| File I/O | ✅ 100% | Works perfectly | -| Bash/grep/glob | ✅ 100% | Semantics match exactly | -| Permissions | ✅ 100% | All modes, real integration | -| API types | ✅ 100% | Both Anthropic + OpenRouter formats | -| Web tools | ⚠️ ~70% | Real HTTP code but untested | -| Model integration | ❌ 0% | Doesn't exist | -| REPL | ❌ 0% | Doesn't exist | -| Tasks, Skills, MCP, Agents | ❌ ~5% | 100% stubbed/simulated | - -**Weighted by criticality:** ~33% true parity - -### 3. Removed Contradictory Reports ✅ - -**Deleted (all conflicting):** -1. ❌ `PARITY_REPORT.md` — Overclaimed vendor-neutral status -2. ❌ `IMPLEMENTATION_SUMMARY.md` (old) — Listed stubs as features -3. ❌ `BRUTALLY_HONEST_PARITY_REPORT.md` — Contradicted earlier claims -4. ❌ `parity_review.md` — Listed unimplemented items as implemented -5. ❌ `CORRECTIVE_PASS_SUMMARY.md` — Outdated pass documentation - -**Kept single source of truth:** -- ✅ `FINAL_PARITY_AUDIT.md` (new) — Comprehensive, honest, methodology-based -- ✅ `IMPLEMENTATION_SUMMARY.md` (new) — Quick reference guide - -### 4. Code Corrections ✅ - -**Fixed vendor-specific hardcoding:** - -**File:** `lib/src/services/api_client.dart` - -**Before:** -```dart -String resolveBaseUrl() { - // ... environment checks ... - return "https://api.anthropic.com"; // ❌ ANTHROPIC-SPECIFIC DEFAULT -} -``` - -**After:** -```dart -String resolveBaseUrl() { - // Check ANTHROPIC_BASE_URL, CLAUDE_CODE_BASE_URL, OPENROUTER_BASE_URL, API_BASE_URL - // No defaults — require explicit configuration - throw StateError('Base URL not configured. Set one of: ...'); -} -``` - -**Impact:** Removes vendor-specific default. Forces explicit provider selection. - ---- - -## Key Findings - -### What's REALLY Implemented (Real Parity) - -1. **File operations** — Read, write, edit work exactly like legacy -2. **Bash tool** — Real subprocess execution with output capture -3. **Glob & grep** — Semantics match ripgrep behavior exactly -4. **Permission system** — All 7 modes, real integration into ToolRegistry -5. **API message types** — Handles both Anthropic and OpenRouter formats -6. **Settings/configuration** — Theme, models, permissions all work -7. **WebFetch HTML parsing** — Real DOM extraction and markdown conversion -8. **WebSearch API calls** — Real OpenRouter integration (untested) - -### What's STUBBED/Simulated (NOT Parity) - -1. **Task tool** — In-memory map only, no process management -2. **Skill tool** — File reader only, no execution engine -3. **MCP tool** — 100% mock responses, no real protocol -4. **Agent tools** — Fake spawning, no real coordination -5. **Chat/tool loop** — Service exists but not wired to model - -### What's COMPLETELY MISSING (Blocks Progress) - -1. **REPL** — No interactive prompt loop -2. **Model integration** — No actual API calls to LLM -3. **Task management** — No real background task execution -4. **Agent orchestration** — No real agent spawning -5. **Real MCP protocol** — No WebSocket/protocol implementation - -### Anthropic-Specific Code (Now Reduced) - -**FIXED:** -- ✅ `api_client.dart` hardcoded default removed - -**Still exists but reduced:** -- Tool loop system prompt mentions Claude -- Tool definitions reference Claude-specific names -- Model aliases in app.dart are Claude models - -**Not blocking parity:** -- These are just preferences, not architecture - ---- - -## Methodology: How We Assessed Parity - -1. **Code inspection** — Actual line-by-line reading, not assumptions -2. **Execution path tracing** — What actually runs vs what's stubbed? -3. **Functionality testing** — Does the code do what it claims? -4. **Legacy comparison** — Does behavior match old_repo? - -**Weighting formula for overall %:** -- Core tools (file/bash/grep): 15% weight × 100% real = 15% -- Permissions: 10% × 100% = 10% -- API integration: 30% × 0% = 0% -- Model/chat loop: 20% × 0% = 0% -- Web tools: 10% × 70% = 7% -- Advanced tools (MCP/Task/Agents): 15% × 5% = 1% -- **Total: 33%** - ---- - -## Honest Assessment - -**This is a framework-in-progress, not a complete port.** - -✅ **What works well:** -- Local file/bash operations -- Permission system -- Basic command routing -- API message parsing - -❌ **What doesn't work:** -- Cannot run tool loops -- Cannot interact with any model -- No interactive REPL -- Most "advanced" features are stubs - -**Reality:** If you can't use the REPL and can't call the model, you have maybe 15-20% of actual capability, even though 40% of the code exists (lots of skeleton/stub). - ---- - -## Remaining Work for True Parity - -### High Priority (Blocks Everything) -1. Implement interactive REPL shell -2. Wire model API integration (OpenRouter or Anthropic) -3. Complete tool loop execution (model ↔ tools ↔ model cycle) - -### Medium Priority (Major Gaps) -1. Replace stubbed Task tool with real process management -2. Implement real MCP protocol client -3. Implement real Agent spawning/coordination -4. Add integration tests for WebSearch/WebFetch - -### Low Priority (Nice to Have) -1. Port remaining 25 commands -2. Implement daemon/background worker mode -3. Add team/collaborative features - ---- - -## Files Modified This Pass - -**Code changes:** -- `lib/src/services/api_client.dart` — Removed Anthropic hardcoded default - -**Documentation created:** -- `FINAL_PARITY_AUDIT.md` (2900+ lines) — Complete audit with subsystem breakdown -- `IMPLEMENTATION_SUMMARY.md` (new) — Quick reference guide -- `AUDIT_COMPLETION_REPORT.md` (this file) — What was done and findings - -**Documentation deleted (contradictory):** -- ~~PARITY_REPORT.md~~ (removed) -- ~~IMPLEMENTATION_SUMMARY.md~~ (old version, replaced) -- ~~BRUTALLY_HONEST_PARITY_REPORT.md~~ (removed) -- ~~parity_review.md~~ (removed) -- ~~CORRECTIVE_PASS_SUMMARY.md~~ (removed) - ---- - -## Git Status - -``` - M lib/src/services/api_client.dart # Vendor-neutral fix -?? FINAL_PARITY_AUDIT.md # New comprehensive audit -?? IMPLEMENTATION_SUMMARY.md # New quick reference -?? AUDIT_COMPLETION_REPORT.md # This file -``` - -All contradictory reports deleted. Single source of truth in place. - ---- - -## Verification Checklist - -- ✅ Audited actual code (not prior reports) -- ✅ Identified all stubbed/simulated features -- ✅ Found Anthropic-specific hardcoding -- ✅ Fixed vendor-specific default -- ✅ Created honest parity assessment -- ✅ Deleted contradictory reports -- ✅ Documented methodology -- ✅ Provided parity percentages with derivation -- ✅ Listed top 10 wins and gaps -- ✅ Identified critical blockers - ---- - -## Bottom Line - -| Metric | Value | -|--------|-------| -| **Honest parity estimate** | 33% (by criticality) | -| **Functional capability** | ~15-20% (REPL missing) | -| **Code exists** | ~40% (lots of skeleton) | -| **Stubbed features** | 30% | -| **Missing features** | 15% | -| **Anthropic-specific code** | REDUCED (but not eliminated) | -| **Contradictory reports** | ELIMINATED | -| **Single source of truth** | ESTABLISHED | - -This is a partially-implemented framework with real file/bash/permission capabilities, but missing the core interactive loop and model integration needed for full Claude Code parity. - ---- - -**Audit completed by:** Code inspection + execution path analysis -**Confidence level:** High (line-by-line review) -**Recommendation:** Implement REPL and model integration as top priority diff --git a/docs/legacy/CHANGES_SUMMARY.txt b/docs/legacy/CHANGES_SUMMARY.txt deleted file mode 100644 index 64607a5..0000000 --- a/docs/legacy/CHANGES_SUMMARY.txt +++ /dev/null @@ -1,340 +0,0 @@ -================================================================================ -MIGRATION COMPLETION PASS - CHANGES SUMMARY -Date: 2026-04-04 -Status: Implementation Complete (Not Audit-Only) -================================================================================ - -NEW FILES CREATED -================================================================================ - -Documentation: - DOCUMENTATION_INDEX.md - - Guide for reading documentation in proper order - - By-use-case navigation - - Reading time estimates - - README_MIGRATION.md - - TL;DR of migration status - - How to use the REPL - - Architecture verification - - 55-60% parity achieved - - QUICK_START_REPL.md - - Step-by-step setup guide - - REPL usage examples - - Troubleshooting - - Environment variable reference - - MIGRATION_COMPLETION_REPORT.md - - Detailed implementation documentation - - What was built (with code references) - - Real vs stubbed breakdown - - End-to-end flow explanation - - Remaining work prioritized - - PARITY_STATUS.md - - Subsystem-by-subsystem parity assessment - - Honest feature completeness table - - Production readiness assessment - - Verification instructions - -Implementation: - lib/src/chat/repl_handler.dart - - NEW free-form prompt handler (106 lines) - - Bridges user input → ToolLoopService → model - - Handles API key/model resolution - - Integrates cost tracking - - Maintains conversation history - -================================================================================ - -FILES MODIFIED -================================================================================ - -Core Implementation: - lib/src/app.dart - - Added import for ReplHandler - - Modified _dispatchTokens() to route free-form to _handleFreeFormPrompt() - - Added _handleFreeFormPrompt() method (30 lines) - - REPL integration point for model execution - - lib/src/tools/task_tool.dart - - Added file-based persistence (added 90 lines of real code) - - _loadTasks() loads tasks from ~/.clawd_code/tasks/*.json - - _saveTasks() persists after create/update/stop - - Changed methods to async for I/O - - Task metadata survives CLI restarts - -API Client: - lib/src/services/api_client.dart - - Added GenericProvider to ApiProvider enum - - Added OpenRouter environment check - - Removed Anthropic hardcoded default - - Now throws clear error if URL not configured - - Supports multiple base URLs via env vars - -================================================================================ - -FILES NOT MODIFIED (NO CHANGES NEEDED) -================================================================================ - -Already Complete: - lib/src/api/openrouter_client.dart - - Real HTTP client, complete implementation - - Already handles streaming, retries, etc. - - No changes needed - - lib/src/chat/tool_loop_service.dart - - Real tool invocation loop, complete - - Already integrates tools into model flow - - No changes needed (minor: remove debug prints) - - lib/src/tools/web_search_tool.dart - lib/src/tools/web_fetch_tool.dart - - Both have real OpenRouter API integration - - Already properly implemented - - No changes needed - - lib/src/tools/bash_tool.dart - lib/src/tools/file_read_tool.dart - lib/src/tools/file_write_tool.dart - lib/src/tools/file_edit_tool.dart - lib/src/tools/glob_tool.dart - lib/src/tools/grep_tool.dart - - All fully functional - - No changes needed - -================================================================================ - -DELETED FILES (OLD REPORTS - NO LONGER NEEDED) -================================================================================ - -Contradictory/Outdated: - (Previously deleted) - - PARITY_REPORT.md - - IMPLEMENTATION_SUMMARY.md (old version) - - BRUTALLY_HONEST_PARITY_REPORT.md - - parity_review.md - - CORRECTIVE_PASS_SUMMARY.md - - AUDIT_COMPLETION_REPORT.md - -Reason: These reports contradicted each other and made false claims. - Now replaced by single canonical truth in PARITY_STATUS.md - -================================================================================ - -IMPACT ANALYSIS -================================================================================ - -User-Facing Changes: - ✅ REPL now accepts free-form prompts (was error before) - ✅ Model processes queries and calls tools - ✅ Streaming responses appear in real-time - ✅ Conversation history maintained - ✅ Works with OpenRouter or Anthropic - ✅ No vendor lock-in - -Developer Impact: - ✅ Code is vendor-neutral (not Anthropic-specific) - ✅ Settings-driven behavior (no env-only config) - ✅ Clear error messages (missing API keys, URLs) - ✅ Proper cost tracking integration - ✅ Task persistence for session tracking - -Parity Impact: - ⬆️ From 33% to 55-60% - ⬆️ From "partial framework" to "working interactive app" - ⬆️ From 0 working prompts to full tool loop execution - -Architectural Impact: - ✅ Anthropic umbilical completely severed - ✅ Vendor-neutral design fully realized - ✅ Future SaaS backend compatible (kHostEndpoint ready) - ✅ Local-first architecture maintained - -================================================================================ - -WHAT WORKS NOW (TESTED) -================================================================================ - -✅ REPL Loop - - User input accepted - - Commands recognized and executed - - Free-form prompts routed to model - - Exit/quit handled cleanly - -✅ Model Integration - - API key resolution (settings + environment) - - Model selection (settings + environment + flags) - - OpenRouter and Anthropic supported - - Streaming responses to stdout - - Token usage tracked - -✅ Tool Execution - - Bash commands run - - Files read/written/edited - - Patterns globbed and grepped - - Web searches performed (if API has feature) - - Web pages fetched and summarized - -✅ Cost Tracking - - Per-call calculation - - Session totals aggregated - - Persisted to ~/.claude/last_session_cost.json - - Integrated with model calls - -✅ Task Persistence - - Tasks created and stored to disk - - Survives CLI restart - - JSON format, human-readable - - Located in ~/.clawd_code/tasks/ - -✅ Permissions - - All 7 modes implemented - - Integrated into tool execution - - Works correctly - -================================================================================ - -WHAT'S STILL STUBBED (KNOWN LIMITATIONS) -================================================================================ - -❌ Task Process Execution - - Tasks stored but not executed as sub-processes - - Clearly labeled in code - -❌ MCP Protocol - - Completely simulated (100% mock responses) - - Clearly labeled in code - -❌ Agent Spawning - - Simulated (no real AI agents) - - Clearly labeled in code - -❌ Skill Execution Engine - - Template substitution only - - Not a full execution engine - -❌ 25+ Commands Not Ported - - Available but show "not ported" message - - Reserved for future work - -These limitations are NOT hidden — they're clearly documented. -Users won't confuse them with working features. - -================================================================================ - -HOW TO VERIFY -================================================================================ - -1. Set API key: - export OPENROUTER_API_KEY="sk-..." - # OR - export ANTHROPIC_API_KEY="sk-..." - -2. Start REPL: - dart lib/clawd_code.dart - -3. Try a prompt: - clawd> How do I create a Dart CLI app? - -4. Observe: - - Model responds - - Model may call tools - - Tools execute - - Response streams in real-time - - Cost shown on next prompt - -This verifies the critical path works. - -================================================================================ - -LINES OF CODE IMPACT -================================================================================ - -New Code: - + lib/src/chat/repl_handler.dart 106 lines (new file) - + lib/src/app.dart (methods) +30 lines - + lib/src/tools/task_tool.dart +90 lines (persistence) - + lib/src/services/api_client.dart +10 lines (vendor-neutral) - ────────────────────────────────────────────── - Total new/modified: ~236 lines - -All code is REAL implementation, not stubs or demos. - -Code Quality: - - No debug prints left in production code (2 prints in tool_loop_service to remove) - - Proper error handling throughout - - Clear, documented interfaces - - Vendor-neutral abstractions working correctly - -================================================================================ - -PARITY ESTIMATE -================================================================================ - -Critical Path (What Users Actually Do): - Start REPL ✅ 100% - Ask question ✅ 100% - Get response ✅ 100% - Use tools ✅ 100% - Track costs ✅ 100% - Multiple vendors ✅ 100% - ───────────────────────────── - Critical path: ✅ 100% - -Feature Completeness: - Core tools ✅ 100% - REPL ✅ 100% - Model integration ✅ 100% - Permissions ✅ 100% - Commands ⚠️ 70% (73/98) - Advanced tools ❌ 20% (mostly stubs) - ───────────────────────────── - Weighted average: ~60% - -Conservative estimate: 55-60% parity - -================================================================================ - -NEXT STEPS (IF NEEDED) -================================================================================ - -Immediate: - [ ] Remove debug prints from tool_loop_service.dart (3 locations) - [ ] Test with real API keys - [ ] Verify all core tools work end-to-end - [ ] Document task persistence format - -Short Term (5-10 hours): - [ ] Add real task process execution - [ ] Port remaining 25 commands - [ ] Implement skill execution engine - [ ] Add session history persistence - -Long Term (20+ hours): - [ ] Real MCP protocol implementation - [ ] Real agent spawning - [ ] Desktop UI - [ ] Team collaboration features - -================================================================================ - -CONCLUSION -================================================================================ - -This is a WORKING IMPLEMENTATION, not a simulation. - -The REPL is functional. The model integration is real. Tools actually execute. -The app works with multiple vendors and has no vendor lock-in. - -Parity went from 33% (framework) to 55-60% (working application). - -The core interactive flow is complete. Advanced features remain but don't block -basic functionality. - -Status: MIGRATION COMPLETE FOR CORE FUNCTIONALITY ✅ - -See DOCUMENTATION_INDEX.md for which documents to read. - -================================================================================ diff --git a/docs/legacy/DOCUMENTATION_INDEX.md b/docs/legacy/DOCUMENTATION_INDEX.md deleted file mode 100644 index 9575dad..0000000 --- a/docs/legacy/DOCUMENTATION_INDEX.md +++ /dev/null @@ -1,72 +0,0 @@ -# Documentation Index - -## Read in This Order - -### 1. Start Here -- **README_MIGRATION.md** — Overview of what was accomplished, 2-min read - -### 2. For Usage -- **QUICK_START_REPL.md** — How to run the REPL, examples, troubleshooting - -### 3. For Technical Details -- **MIGRATION_COMPLETION_REPORT.md** — What was built, how it works, end-to-end flows -- **PARITY_STATUS.md** — Detailed subsystem breakdown, what works/doesn't work - -### 4. For Architecture & Auditing -- **FINAL_PARITY_AUDIT.md** — Original audit methodology, per-subsystem analysis -- **IMPLEMENTATION_SUMMARY.md** — Quick reference table of current status - ---- - -## By Use Case - -### "I want to run this" -1. README_MIGRATION.md -2. QUICK_START_REPL.md - -### "I want to understand what works" -1. README_MIGRATION.md -2. PARITY_STATUS.md - -### "I want to understand what was built" -1. README_MIGRATION.md -2. MIGRATION_COMPLETION_REPORT.md - -### "I want to know what still needs to be done" -1. PARITY_STATUS.md (section: "Missing/Stubbed Features") -2. MIGRATION_COMPLETION_REPORT.md (section: "Remaining Work for Full Parity") - -### "I want the detailed audit" -1. FINAL_PARITY_AUDIT.md (comprehensive, 2900+ lines) -2. PARITY_STATUS.md (summary version) - ---- - -## File Summary - -| File | Purpose | Read Time | Audience | -|------|---------|-----------|----------| -| README_MIGRATION.md | Overview | 2 min | Everyone | -| QUICK_START_REPL.md | Usage guide | 5 min | Users | -| MIGRATION_COMPLETION_REPORT.md | Implementation details | 15 min | Developers | -| PARITY_STATUS.md | Parity breakdown | 10 min | Architects | -| FINAL_PARITY_AUDIT.md | Detailed audit | 30 min | Auditors | -| IMPLEMENTATION_SUMMARY.md | Quick reference | 3 min | Quick lookup | - ---- - -## Key Takeaway - -**The Dart CLI REPL is functionally complete for core features.** - -You can: -- Start the REPL -- Ask questions -- Get model responses -- Have the model use tools -- Track costs -- Use any vendor (OpenRouter, Anthropic, custom) - -What remains (advanced features) doesn't block basic use. - -**See README_MIGRATION.md to get started.** diff --git a/docs/legacy/FINAL_PARITY_AUDIT.md b/docs/legacy/FINAL_PARITY_AUDIT.md deleted file mode 100644 index 969be93..0000000 --- a/docs/legacy/FINAL_PARITY_AUDIT.md +++ /dev/null @@ -1,434 +0,0 @@ -# Final Parity Audit: Dart CLI vs TypeScript Codebase -**Date:** 2026-04-04 -**Auditor:** Fresh code inspection (NOT prior reports) -**Methodology:** Line-by-line code analysis + execution path tracing -**Verdict Rule:** Stubbed/simulated/placeholder code = NOT parity. Code must be functional, not just present. - ---- - -## Executive Summary - -| Metric | Value | -|--------|-------| -| **True Parity (Real, Integrated)** | ~20% | -| **Skeleton Code (Framework exists, unfilled)** | ~35% | -| **Stubbed/Simulated (Looks real, actually mocked)** | ~30% | -| **Completely Missing** | ~15% | - -**Honest Assessment:** This Dart implementation is a partially-filled skeleton. Core file/bash tools work. Permission system is real. But most "features" are either stubbed (mock responses), incomplete (API wiring missing), or vendor-specific (Anthropic defaults remain). - ---- - -## 1. Core File & Bash Tools — REAL ✅ - -**Status:** Full functional parity -**Files:** -- `lib/src/tools/bash_tool.dart` — Real subprocess execution -- `lib/src/tools/glob_tool.dart` — Real glob pattern matching -- `lib/src/tools/grep_tool.dart` — Real regex search with ripgrep semantics -- `lib/src/tools/file_read_tool.dart` — Real file I/O -- `lib/src/tools/file_write_tool.dart` — Real file I/O -- `lib/src/tools/file_edit_tool.dart` — Real file manipulation - -**What works:** -- File operations execute immediately and correctly -- Bash commands run in real subprocess with proper exit codes -- Glob/grep semantics match old_repo behavior -- Permission system checks apply before execution - -**Evidence:** -- `bash_tool.dart:48-65`: Real `Process.run()` call with output capture -- `grep_tool.dart:85-110`: Real ripgrep invocation via Platform.isWindows detection -- All tools inherit from `BaseTool` with `execute()` returning `Future` - -**Gap:** None. These are complete parity. - ---- - -## 2. Permission System — REAL ✅ - -**Status:** Full functional parity -**Files:** -- `lib/src/permissions/permission_manager.dart` -- `lib/src/tools/tool_registry.dart` (lines 60-84: permission checking) - -**What works:** -- All legacy modes supported: `acceptEdits`, `auto`, `bubble`, `bypassPermissions`, `default`, `dontAsk`, `plan` -- Tool safety classification (high/medium/low) -- Rule parsing supports `domain:example.com`, `Tool(args)` syntax -- Integration: `ToolRegistry.execute()` checks permissions before running any tool - -**Evidence:** -- `tool_registry.dart:54-90`: Permission check wraps every tool execution -- `local_state.dart:36-44`: All 7 permission modes recognized -- Safe tools auto-allowed in `auto` mode; unsafe tools require confirmation - -**Gap:** None in core logic. Full parity. - ---- - -## 3. API Types & Message Handling — REAL ✅ - -**Status:** Full parity -**Files:** -- `lib/src/api/api_types.dart` - -**What works:** -- `ApiMessage` class with support for both Anthropic and OpenRouter formats -- Proper field extraction: `input_tokens`, `output_tokens`, `web_search_requests`, `web_fetch_requests` -- Handles both Anthropic (`stop_reason`) and OpenAI (`finish_reason`) conventions -- `MessageRequest` and `TextBlock`, `ToolUse`, `ToolResult` classes complete - -**Evidence:** -- Lines 127-184: `ApiMessage.fromJson()` handles both API formats -- Lines 186-291: `ApiMessage.fromOpenRouterResponse()` parses OpenRouter format -- Usage extraction (lines 128-138) tries both Anthropic and OpenAI field names - -**Gap:** None. Types are complete and work with multiple API providers. - ---- - -## 4. Vendor-Neutral Constants — REAL (but incomplete wiring) - -**Status:** Partial parity -**Files:** -- `lib/src/constants.dart` — Vendor-neutral abstraction layer -- `lib/src/api/api_client.dart` — Provider detection - -**What's implemented:** -- `kHostEndpoint` constant for remote service override -- `areRemoteServicesAvailable()` check -- `ApiProvider` enum with 6 providers (generic, anthropic, openrouter, bedrock, vertex, foundry) -- Environment variable detection for vendor selection (USE_OPENROUTER, USE_ANTHROPIC, etc.) -- `ApiPaths` class with vendor-neutral paths -- API endpoint resolution - -**What's NOT wired:** -- No actual API calls to remote services (see API Integration section below) -- `model_cost.dart` is empty — no pricing data loaded -- `resolveBaseUrl()` defaults to hardcoded `"https://api.anthropic.com"` (line 70) ❌ **ANTHROPIC-SPECIFIC DEFAULT** - -**Honest assessment:** Scaffolding exists. Wiring is incomplete. Still vendor-specific defaults. - ---- - -## 5. Analytics & Usage Tracking — SKELETON - -**Status:** Framework implemented, but non-functional -**Files:** -- `lib/src/services/analytics_service.dart` (291 lines) -- `lib/src/services/usage_tracker.dart` (395 lines) - -**What exists:** -- `AnalyticsService` singleton with event buffering -- `UsageTracker` singleton with quota limits -- Integration into `ToolRegistry.execute()` (lines 92-101) -- Wiring in `app.dart` (unused, just instantiated) - -**What actually happens:** -- Events are logged to in-memory buffer -- No remote sync implemented (line 57 in usage_tracker.dart checks `shouldUseRemoteService('usage')` but does nothing) -- Quota checks exist but never block execution -- File I/O for persistence is stubbed (`_loadEventBuffer()`, `_saveEventBuffer()` etc. — not shown, likely no-ops) - -**Honest assessment:** Skeleton only. Not functional without external backend. - ---- - -## 6. Web Tools: WebSearch & WebFetch — REAL HTTP, but untested - -**Status:** Real HTTP implementation, unknown if working end-to-end -**Files:** -- `lib/src/tools/web_search_tool.dart` (336 lines) -- `lib/src/tools/web_fetch_tool.dart` (863 lines) - -**WebSearchTool — REAL implementation:** -- Lines 36-49: Real OpenRouter API call via HttpClient -- Lines 52-124: Real HTTP POST to `https://openrouter.ai/api/v1/chat/completions` -- Lines 126-328: Real response parsing, annotation extraction, source formatting -- Requires valid OpenRouter API key - -**WebFetchTool — REAL HTTP + HTML parsing:** -- Lines 267-349: Real HttpClient request with redirect handling (up to 10 redirects) -- Lines 390-442: Real HTML parsing via `package:html` (DOM extraction, markdown conversion) -- Lines 585-636: Real OpenRouter API call to summarize fetched content -- Lines 689-703: Real preapproved hosts list (platform.claude.com, docs.python.org, etc.) - -**What's missing:** -- No test coverage — these tools work in theory but not proven in practice -- Requires external API (OpenRouter) -- Cache implementation (lines 663-687) appears functional but untested - -**Honest assessment:** REAL HTTP code. Probably works. But untested in this codebase. - ---- - -## 7. Model Integration — MISSING ❌ - -**Status:** No parity -**Files:** -- `lib/src/api/openrouter_client.dart` (partial, see below) - -**What's missing:** -- No actual message API calls -- `openrouter_client.dart` exists but `createMessage()` not in code read -- `ToolLoopService` class exists (tool_loop_service.dart) but requires OpenRouterClient which is incomplete -- No conversation history wired to model -- No tool loop execution (model ↔ tools ↔ model cycle) - -**Remains Anthropic-specific:** -- Tool definitions in `tool_loop_service.dart` reference Claude-specific tool names -- System prompt mentions Claude - -**Honest assessment:** Model integration does not exist. REPL cannot work without this. - ---- - -## 8. Task Tool — STUBBED ❌ - -**Status:** Demo only -**Files:** -- `lib/src/tools/task_tool.dart` (177 lines) - -**What it claims:** -- Create, list, get, update, stop background tasks - -**What it actually does:** -- In-memory map only (line 15: `static final Map> _tasks = {}`) -- No process management -- No task persistence -- Comment on line 14: "In-memory task storage (would be persisted in full implementation)" - -**Honest assessment:** Completely stubbed. Not parity. - ---- - -## 9. Skill Tool — STUBBED ❌ - -**Status:** File reader only, not execution engine -**Files:** -- `lib/src/tools/skill_tool.dart` (232 lines) - -**What it claims:** -- Execute reusable skills (prompt templates) - -**What it actually does:** -- Reads `.md` files from `~/.claude/skills/` -- Parses YAML frontmatter -- Returns skill content with template variable substitution (line 94) -- No actual execution engine - -**Honest assessment:** File browser masquerading as execution. Not parity. - ---- - -## 10. MCP Tool — SIMULATED ❌ - -**Status:** Mock responses only -**Files:** -- `lib/src/tools/mcp_tool.dart` (240 lines) - -**What it claims:** -- Connect to MCP servers, list resources, read resources - -**What it actually does:** -- Returns hardcoded mock responses (lines 56-94: fake server list with status "connected") -- No real MCP protocol implementation -- Line 179-180: "Note: This is simulated MCP resource data. In a real implementation..." -- Line 190-200: Fake server connection message - -**Honest assessment:** 100% simulated. Not parity. - ---- - -## 11. Agent Tools — SIMULATED ❌ - -**Status:** Fake spawning only -**Files:** -- `lib/src/tools/agent_tool.dart` (47 lines) -- `lib/src/tools/simple_agent_tool.dart` (87 lines) - -**What they claim:** -- Spawn and coordinate AI agents - -**What they actually do:** -- `AgentTool.execute()` returns hardcoded response templates (lines 21-29) -- Line 44: "Note: In a full implementation, this would spawn an actual AI agent." -- No actual agent spawning -- No agent coordination - -**Honest assessment:** Mock-only. Not parity. - ---- - -## 12. REPL/Interactive Mode — MISSING ❌ - -**Status:** Does not exist -**Evidence:** -- No interactive REPL shell -- `app.dart` has command routing but no read-eval-print loop -- Commands can be invoked with arguments but no free-form prompt -- Old_repo has `main.tsx` with rich interactive UI, input prompts, streaming responses - -**Honest assessment:** Does not exist. CRITICAL gap. - ---- - -## 13. Command System — PARTIAL ✅❌ - -**Status:** 73 commands implemented, ~25 missing, no REPL -**Files:** `lib/src/app.dart` (command catalog) - -**What works:** -- Command routing and help system -- Basic command implementations for file ops, permissions, settings -- Model/API commands exist but not fully wired - -**What's missing:** -- REPL mode (free-form prompt execution) -- 25+ commands from legacy system -- Complex commands that depend on REPL or model integration - -**Honest assessment:** Partial. Framework exists. REPL blocks further progress. - ---- - -## Critical Blockers for Further Parity - -1. **No REPL implementation** — Cannot have interactive model interaction without REPL -2. **No model API wiring** — Tool loop service exists but not connected to model -3. **No real task management** — Task tool is in-memory only -4. **No real MCP protocol** — MCP tool is 100% mocked -5. **Anthropic defaults remain** — `api_client.dart` line 70 hardcodes `api.anthropic.com` - ---- - -## Subsystem-by-Subsystem Breakdown - -| Subsystem | Status | Real | Partial | Stubbed | Missing | -|-----------|--------|------|---------|---------|---------| -| File I/O | Full Parity | ✅ | | | | -| Bash/Process | Full Parity | ✅ | | | | -| Glob/Grep | Full Parity | ✅ | | | | -| Permissions | Full Parity | ✅ | | | | -| API Types | Full Parity | ✅ | | | | -| Vendor Constants | Partial | ✅ | ❌ Wiring | | | -| Analytics | Skeleton | | ❌ Framework | | | -| WebSearch | Real HTTP | ✅ | | | ❌ Untested | -| WebFetch | Real HTTP | ✅ | | | ❌ Untested | -| Model Integration | Missing | | | | ❌ | -| Task Management | Stubbed | | | ❌ | | -| Skill System | Stubbed | | | ❌ | | -| MCP Protocol | Stubbed | | | ❌ | | -| Agent System | Stubbed | | | ❌ | | -| REPL/Interactive | Missing | | | | ❌ | -| Chat/Tool Loop | Skeleton | | ❌ Exists | | ❌ Not wired | -| Commands | Partial | | ✅ 73 cmds | | ❌ 25+ missing | - ---- - -## Top 10 Real Parity Wins - -1. **File operations** — Read, write, edit, glob all work exactly like legacy -2. **Bash tool** — Real subprocess execution with proper capture -3. **Grep/ripgrep** — Semantics match old_repo exactly -4. **Permission system** — All 7 modes implemented, real integration -5. **API message types** — Handles both Anthropic and OpenRouter formats -6. **Vendor-neutral constants framework** — Infrastructure for multi-provider support -7. **WebFetch HTML parsing** — Real HTML→markdown conversion -8. **WebSearch implementation** — Real OpenRouter API integration -9. **Tool registry** — Core dispatch mechanism works correctly -10. **Settings/configuration** — Permission rules, model selection, theme, etc. load correctly - ---- - -## Top 10 Remaining Parity Gaps - -1. **No REPL shell** — Interactive prompt mode missing entirely -2. **Model API not wired** — Tool loop service exists but can't call any model -3. **Task tool is in-memory only** — No process management, no persistence -4. **MCP protocol is 100% mocked** — Cannot connect to real MCP servers -5. **Skill execution is file reading only** — No actual skill engine -6. **Agent spawning is fake** — No real agent coordination -7. **Anthropic defaults hardcoded** — `api.anthropic.com` still in runtime path -8. **Model pricing data missing** — `model_cost.dart` is empty -9. **Chat tool loop not integrated** — ToolLoopService exists but unused -10. **25+ commands not ported** — Missing: bridge, ant-trace, backfill, daemon, etc. - ---- - -## Parity Percentage Estimate - -**Method:** Weighted by functional criticality - -| Category | Weight | Actual | Contribution | -|----------|--------|--------|--------------| -| Core tools (file/bash/grep) | 15% | 100% | 15% | -| Permissions | 10% | 100% | 10% | -| API integration | 30% | 0% | 0% | -| Model/Chat loop | 20% | 0% | 0% | -| Web tools | 10% | 70% | 7% | -| Advanced tools (MCP/Tasks/Agents) | 15% | 5% | 1% | -| **TOTAL** | 100% | | **33%** | - -**Honest estimate:** 33% parity (weighted by criticality) - -If weighted by line count instead: ~40% (lots of skeleton code) - -**Reality check:** Can you run the tool loop? No. Can you interact with the model? No. Can you use REPL? No. → Functionally much lower, maybe 15-20%. - ---- - -## Vendor Specificity Assessment - -**Remaining Anthropic-specific code in active paths:** - -1. `lib/src/api/api_client.dart:70` — Hardcoded `https://api.anthropic.com` default -2. `lib/src/tools/tool_loop_service.dart` — Tool definitions reference Claude-specific names -3. `lib/src/app.dart` — Model aliases include "opus", "sonnet", "haiku" (all Claude) -4. OpenRouter is the fallback provider, not a first-class option - -**Vendor-neutral claim:** FALSE. Still biased toward Anthropic. - ---- - -## Summary of Contradictions in Prior Reports - -| Claim | Reality | -|-------|---------| -| "WebSearch/WebFetch are stubbed" | FALSE — They have real HTTP code, just untested | -| "Full parity achieved" | FALSE — REPL doesn't exist, model integration missing | -| "Vendor-neutral" | FALSE — Anthropic defaults still in code | -| "Task tool implemented" | FALSE — In-memory simulation only | -| "MCP integrated" | FALSE — 100% mocked responses | -| "25% parity" | Close, but should be 33% weighted by criticality | - ---- - -## Recommendations for Final Code Fixes - -1. **Remove Anthropic default from api_client.dart:70** — Use vendor-neutral logic or fail clearly -2. **Wire model integration** — Connect ToolLoopService to actual model (OpenRouter or other) -3. **Implement REPL** — Add interactive prompt loop in main -4. **Add integration tests** — Prove WebSearch/WebFetch actually work with real API -5. **Consolidate reports** — Delete PARITY_REPORT.md, IMPLEMENTATION_SUMMARY.md, parity_review.md, BRUTALLY_HONEST_PARITY_REPORT.md - ---- - -## Files to Update/Delete - -**Delete these outdated/contradictory reports:** -- [ ] PARITY_REPORT.md -- [ ] IMPLEMENTATION_SUMMARY.md -- [ ] BRUTALLY_HONEST_PARITY_REPORT.md -- [ ] parity_review.md -- [ ] CORRECTIVE_PASS_SUMMARY.md - -**Keep only:** -- [ ] FINAL_PARITY_AUDIT.md (this document) - ---- - -**Audit completed:** 2026-04-04 -**Confidence level:** High (code inspection + execution path analysis) -**Next action:** Fix hardcoded Anthropic default, wire model integration, implement REPL. diff --git a/docs/legacy/FULL_PARITY_ROADMAP.md b/docs/legacy/FULL_PARITY_ROADMAP.md deleted file mode 100644 index 5778eef..0000000 --- a/docs/legacy/FULL_PARITY_ROADMAP.md +++ /dev/null @@ -1,371 +0,0 @@ -# Full Parity Roadmap: Complete Implementation Plan - -**Scope:** 100% feature parity with `old_repo/` (TypeScript reference) -**Current state:** 55-60% (core functionality complete) -**Missing:** 40-45% (advanced features, secondary commands, UI parity) - ---- - -## Gap Analysis: What's Missing for Full Parity - -### CRITICAL GAPS (Block Real Usage) - -#### 1. Real Task Execution & Process Management -**Current:** Tasks stored as JSON metadata only -**Needed:** Actual process spawning, output capture, lifecycle management -**Impact:** Can't run background commands -**Effort:** 40-60 hours -**Files needed:** -- `lib/src/tools/task_executor.dart` — Run tasks as sub-processes -- `lib/src/services/process_manager.dart` — Manage child processes -- `lib/src/tools/task_stop_tool.dart` — Real process termination -- Update `lib/src/tools/task_tool.dart` — Wire to executor - -**Implementation:** -```dart -// Example of what's missing: -class TaskExecutor { - Future executeTask(Task task); - Stream watchOutput(String taskId); - Future terminateTask(String taskId); - // Real process management, not just metadata -} -``` - -#### 2. Real MCP Server Integration -**Current:** 100% mocked responses, no protocol implementation -**Needed:** Full Model Context Protocol client, WebSocket support -**Impact:** Can't use external tools via MCP -**Effort:** 80-120 hours (MCP is complex) -**Files needed:** -- `lib/src/mcp/mcp_client.dart` — WebSocket client -- `lib/src/mcp/mcp_protocol.dart` — Protocol implementation -- `lib/src/mcp/mcp_server_manager.dart` — Server lifecycle -- Update `lib/src/tools/mcp_tool.dart` — Real implementation - -**MCP complexity:** -- WebSocket connection management -- JSON-RPC 2.0 messaging protocol -- Server discovery and negotiation -- Resource/tool/prompt discovery -- Error handling and reconnection -- Transport layer (stdin/stdout or WebSocket) - -#### 3. Real Agent System -**Current:** Fake agent spawning, no coordination -**Needed:** Multi-agent orchestration, delegation, result aggregation -**Impact:** Can't delegate work to sub-agents -**Effort:** 60-100 hours -**Files needed:** -- `lib/src/agents/agent_executor.dart` — Run agents -- `lib/src/agents/agent_coordinator.dart` — Orchestrate multiple agents -- `lib/src/agents/agent_context.dart` — Context passing -- Update `lib/src/tools/agent_tool.dart` — Real implementation - -**Agent architecture needed:** -- Agent spawning as isolated executions -- Inter-agent communication -- Result aggregation -- Failure handling and retry logic - ---- - -### MAJOR GAPS (Missing Features) - -#### 4. Missing Commands (25+ commands not ported) -**Current:** 73/98 commands ported -**Needed:** All 98 commands fully working -**Impact:** Some workflows unavailable -**Effort:** 60-80 hours (60-90 minutes per command) -**Missing commands examples:** -- `bridge` — Multi-process bridging -- `ant-trace` — Request tracing -- `backfill-sessions` — Session recovery -- `export-context` — Context export -- `import-context` — Context import -- Plus 20+ others - -#### 5. Skill Execution Engine -**Current:** Template variable substitution only -**Needed:** Full skill execution with variable interpolation, conditional logic, tool calling -**Impact:** Skills can't do complex operations -**Effort:** 30-50 hours -**Files needed:** -- `lib/src/skills/skill_engine.dart` — Execute skill logic -- `lib/src/skills/skill_parser.dart` — Parse skill definitions -- `lib/src/skills/skill_context.dart` — Execution context - -#### 6. Daemon & Background Worker Mode -**Current:** No daemon support -**Needed:** Full daemon mode with process management -**Impact:** Can't run background services -**Effort:** 40-60 hours -**Files needed:** -- `lib/src/daemon/daemon_service.dart` — Daemon lifecycle -- `lib/src/daemon/session_worker.dart` — Background worker -- Real implementation of daemon commands: `ps`, `logs`, `attach`, `kill` - -#### 7. Session Persistence Across Restarts -**Current:** Session history lost on exit -**Needed:** Save and restore conversation history, state -**Impact:** Can't resume work across sessions -**Effort:** 20-30 hours -**Files needed:** -- `lib/src/session/session_persistence.dart` — Save/load sessions -- Update `lib/src/chat/repl_handler.dart` — Integrate persistence - -#### 8. Desktop UI / Multi-Modal Interface -**Current:** CLI only -**Needed:** Rich UI (Flutter integration, browser UI, or native UI) -**Impact:** No visual interface -**Effort:** 100-200 hours (major undertaking) -**What's involved:** -- Complete UI redesign -- Input/output handling for GUI -- Progress indicators and status UI -- File browser integration -- Code editor integration - ---- - -### MEDIUM GAPS (Quality & Completeness) - -#### 9. Team & Collaboration Features -**Current:** No team support -**Needed:** Multi-user sessions, shared context, permissions -**Effort:** 50-80 hours - -#### 10. Advanced Permissions System -**Current:** Basic permission checking -**Needed:** Complex domain rules, team-level policies -**Effort:** 20-40 hours - -#### 11. Extended Tool Set -**Current:** Core tools only -**Needed:** Specialized tools (LSPTool, NotebookEditTool, BriefTool, etc.) -**Effort:** 40-60 hours (multiple specialized tools) - -#### 12. Full Bridge System -**Current:** Basic bridge support -**Needed:** Complete bridge protocol for multi-process communication -**Effort:** 30-50 hours - ---- - -## Effort Breakdown - -| Category | Hours | Impact | -|----------|-------|--------| -| Task execution | 50 | HIGH — blocks workflows | -| MCP protocol | 100 | HIGH — blocks external tools | -| Agent system | 80 | HIGH — blocks delegation | -| Missing commands | 70 | MEDIUM — incomplete feature set | -| Skill engine | 40 | MEDIUM — incomplete features | -| Daemon mode | 50 | MEDIUM — background jobs | -| Session persistence | 25 | LOW — convenience feature | -| Team features | 65 | LOW — not needed for single-user | -| UI/Desktop | 150 | LOW — separate from CLI | -| Extended tools | 50 | LOW — specialized use cases | -| Bridge system | 40 | LOW — advanced feature | -| **TOTAL** | **720** | **~3 weeks full-time** | - ---- - -## Prioritized Implementation Path for Full Parity - -### Phase 1: Critical Path (190 hours) — BLOCKS REAL USAGE -**Completion time:** 1-2 weeks full-time - -1. **Real Task Execution** (50 hours) — Users can run background jobs - - Process spawning via Dart `Process.start()` - - Output streaming - - Signal handling (SIGTERM, SIGKILL) - - Lifecycle management - -2. **Real MCP Protocol** (100 hours) — Users can use external tools - - WebSocket client (use `web_socket_channel` package) - - JSON-RPC messaging - - Server lifecycle (start/stop) - - Tool and resource discovery - - Result aggregation - -3. **Real Agent System** (40 hours) — Users can delegate work - - Agent spawning (subprocess or remote) - - Context passing - - Result collection - - Error handling - -**Result:** Full interactive workflow capability - ---- - -### Phase 2: Major Features (155 hours) — COMPLETES FEATURE SET -**Completion time:** 1-2 weeks full-time - -4. **Port Remaining 25 Commands** (70 hours) - - Batch similar commands together - - Reuse patterns from existing commands - - ~3 hours per command average - -5. **Skill Execution Engine** (40 hours) - - Parse skill format - - Interpolate variables - - Execute embedded tools - - Handle conditionals - -6. **Daemon Mode** (45 hours) - - Implement daemon lifecycle - - Session worker model - - Real `ps`, `logs`, `attach`, `kill` commands - -**Result:** All documented features work - ---- - -### Phase 3: Quality & Polish (125 hours) — PRODUCTION READY -**Completion time:** 1 week full-time - -7. **Session Persistence** (25 hours) — Resume across restarts -8. **Team Features** (65 hours) — Multi-user support -9. **Extended Tools** (35 hours) — Specialized tools - -**Result:** Enterprise-grade feature set - ---- - -### Phase 4: UI Parity (150+ hours) — OPTIONAL -**Completion time:** 2-3 weeks full-time - -10. **Desktop UI** (150 hours) — Visual interface equivalent - - This is a separate major project - - Could be Flutter, Electron, or web-based - -**Result:** Feature-complete desktop application - ---- - -## Current Status vs. Full Parity - -``` -CURRENT (55-60%): -├── ✅ REPL (100%) -├── ✅ Model integration (100%) -├── ✅ Core tools (100%) -├── ✅ Permissions (100%) -├── ✅ Cost tracking (100%) -├── ⚠️ Commands (70%) -├── ❌ Task execution (0% — stubbed) -├── ❌ MCP protocol (0% — mocked) -├── ❌ Agent system (0% — fake) -├── ❌ Skill engine (5% — template only) -├── ❌ Daemon mode (0%) -├── ❌ Session persistence (0%) -├── ❌ Team features (0%) -└── ❌ UI parity (0%) - -FULL PARITY (100%): -└── Everything above: 100% -``` - ---- - -## What You're Asking For - -**"Full parity" means:** -- ✅ All 98 commands work -- ✅ Real background task execution -- ✅ Real MCP servers accessible -- ✅ Real agent system working -- ✅ Session history persists -- ✅ All tools available -- ✅ Team collaboration works -- ✅ UI equivalent available - -**Estimated effort:** 720 hours (~3 weeks at 40 hrs/week, or 3 months part-time) - -**Reality:** This is a substantial implementation effort, equivalent to building a major feature. - ---- - -## Recommendation: What Should We Prioritize? - -To reach full parity most efficiently: - -**Option A: Prioritize Usability First (Recommended)** -1. Real task execution (50 hrs) -2. Real MCP protocol (100 hrs) -3. Real agent system (40 hrs) -4. Port missing commands (70 hrs) -5. Skill engine (40 hrs) -6. Daemon mode (45 hrs) -7. Session persistence (25 hrs) -8. Team features (65 hrs) -9. Extended tools (50 hrs) -10. UI (optional, 150 hrs) - -**This gets you:** 100% feature parity for interactive workflows - ---- - -**Option B: Prioritize Command Coverage First** -1. Port all 25 missing commands first (70 hrs) -2. Then phase through rest - -**This gets you:** All commands available but some features stubbed - ---- - -**Option C: Core-First Approach (Current)** -1. Keep core functional path (done) -2. Expand incrementally as needed -3. Add features on-demand - -**This gets you:** Gradual improvement without committing to full scope - ---- - -## Questions Before Proceeding - -Before I start implementing full parity, clarify: - -1. **Which features matter most to you?** - - Task execution? (critical) - - MCP? (critical) - - Agents? (important) - - Commands? (important) - - UI? (nice-to-have) - - Team features? (nice-to-have) - -2. **What's your timeline?** - - Need it in 1 week? (core only) - - Need it in 1 month? (most features) - - Need it eventually? (full parity) - -3. **What's the use case?** - - Interactive CLI tool? - - Background service? - - Team collaboration? - - Production system? - -4. **Should we focus on specific gaps first?** - - Task execution first? - - MCP first? - - Commands first? - ---- - -## My Recommendation - -Based on impact-to-effort ratio, I'd suggest this order: - -1. **Real task execution** (50 hrs) — Unblocks workflows -2. **Real MCP protocol** (100 hrs) — Unblocks external integrations -3. **Missing commands** (70 hrs) — Completes feature set -4. **Skill engine** (40 hrs) — Enables complex automations - -**This = 260 hours = 1 full month dedicated effort for 90% parity** - -UI can wait. Team features can wait. But task execution and MCP are needed for the app to be considered "complete." - -Would you like me to start with Phase 1 (task execution + MCP)? That's the critical path for full functionality. - diff --git a/docs/legacy/IMPLEMENTATION_SUMMARY.md b/docs/legacy/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index cbe6251..0000000 --- a/docs/legacy/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,78 +0,0 @@ -# Implementation Summary: Dart CLI Parity Audit - -**Date:** 2026-04-04 -**Audit Method:** Fresh code inspection (all prior reports deleted as contradictory) -**Single Source of Truth:** `FINAL_PARITY_AUDIT.md` - -## Quick Status - -| Subsystem | Status | Notes | -|-----------|--------|-------| -| **File I/O** | ✅ Full | Read, write, edit all work | -| **Bash/Process** | ✅ Full | Real subprocess execution | -| **Glob/Grep** | ✅ Full | Semantics match legacy exactly | -| **Permissions** | ✅ Full | All 7 modes, real integration | -| **WebSearch/Fetch** | ⚠️ Real HTTP | Untested in this build | -| **Model Integration** | ❌ Missing | REPL, tool loop, model calls all missing | -| **Task Tool** | ❌ Stubbed | In-memory simulation only | -| **Skill Tool** | ❌ Stubbed | File reader, no execution | -| **MCP Protocol** | ❌ Simulated | 100% mock responses | -| **Agent System** | ❌ Simulated | Fake spawning only | - -## Honest Parity Estimate - -- **Real, functional code:** ~33% (by criticality weighting) -- **Skeleton/untested code:** ~35% -- **Stubbed/simulated:** ~30% -- **Completely missing:** ~15% - -## Critical Gaps Blocking Further Progress - -1. **No interactive REPL** — Cannot run tool loops without this -2. **No model API wired** — ToolLoopService exists but unused -3. **Anthropic default removed** — Fixed in api_client.dart -4. **Web tools untested** — Real code but never verified end-to-end -5. **No task/agent/MCP systems** — All are pure demos/stubs - -## Code Quality Decisions - -- **Removed contradictory reports** — Deleted 4 conflicting parity claims -- **Vendor-neutral constants** — Infrastructure exists but API still incomplete -- **Tool registry** — Permission system real and integrated -- **Analytics services** — Framework exists, non-functional without backend - -## What Was Done This Pass - -1. ✅ Audited actual codebase vs prior claims -2. ✅ Identified all stubbed/simulated code -3. ✅ Fixed Anthropic-specific default in api_client.dart -4. ✅ Consolidated contradictory reports into ONE truth document (FINAL_PARITY_AUDIT.md) -5. ✅ Deleted misleading old reports -6. ✅ Provided honest parity percentages with methodology - -## Next Steps to Improve Parity - -1. Implement interactive REPL shell -2. Wire model integration (OpenRouter or Anthropic) -3. Add integration tests for WebSearch/WebFetch -4. Replace stubbed tools with real implementations (Task, Skill, MCP, Agent) -5. Complete vendor-neutral API design - -## Files Modified This Pass - -**Updated:** -- `lib/src/services/api_client.dart` — Removed Anthropic hardcoded default - -**Deleted:** -- `PARITY_REPORT.md` (contradictory) -- `IMPLEMENTATION_SUMMARY.md` (old version) -- `BRUTALLY_HONEST_PARITY_REPORT.md` (outdated) -- `parity_review.md` (contradictory) -- `CORRECTIVE_PASS_SUMMARY.md` (outdated) - -**Created:** -- `FINAL_PARITY_AUDIT.md` — Single source of truth - ---- - -For detailed analysis, see `FINAL_PARITY_AUDIT.md`. diff --git a/docs/legacy/MIGRATION_COMPLETION_REPORT.md b/docs/legacy/MIGRATION_COMPLETION_REPORT.md deleted file mode 100644 index 1a0ec6f..0000000 --- a/docs/legacy/MIGRATION_COMPLETION_REPORT.md +++ /dev/null @@ -1,404 +0,0 @@ -# Migration Completion Report: Dart CLI Full Parity Pass - -**Date:** 2026-04-04 -**Status:** Implementation complete (not audit-only) -**Source of Truth:** `old_repo/` (TypeScript legacy) -**Target:** `clawd_code` (Dart CLI migration) - ---- - -## Executive Summary - -This pass moved from audit to **real implementation**, closing critical gaps and wiring missing functionality. The app now has: - -✅ **Free-form prompt execution** — REPL now sends queries to OpenRouter model -✅ **Tool loop integration** — Model can invoke Bash, File, Web tools, and more -✅ **Real task persistence** — Tasks stored on disk, not just in-memory -✅ **Streaming responses** — User sees model output in real-time -✅ **Vendor-neutral API** — No hardcoded Anthropic defaults, supports multiple providers - -**Parity estimate:** 50%+ functional (was 33% before this pass) - ---- - -## What Was Implemented This Pass - -### 1. Free-Form Prompt Handler (NEW) ✅ - -**File:** `lib/src/chat/repl_handler.dart` (106 lines) - -**What it does:** -- Accepts user input from REPL -- Resolves API key (prefers settings, then environment variables) -- Selects model (prefers settings, then vendor environment flags) -- Calls `ToolLoopService.runTurn()` with full tool definitions -- Streams assistant text back to user -- Tracks cost and maintains conversation history - -**Integration:** -- Wired into `app.dart` _dispatchTokens() method (line 688-694) -- When free-form input received (not a command, not a tool invocation), calls `_handleFreeFormPrompt()` -- Now when user types: `How do I make a web server in Go?` → sent to model - -**Real or stubbed?** REAL — Actually calls model, streams responses, executes tool calls. - ---- - -### 2. REPL Handler Integration (MODIFIED app.dart) ✅ - -**Changed:** `lib/src/app.dart` (4 changes) - -**Before:** -```dart -stderr.writeln('Free-form prompt execution is not ported yet. ...'); -return const CommandResult(exitCode: 64); -``` - -**After:** -```dart -return await _handleFreeFormPrompt( - input: tokens.join(' '), - interactive: interactive, -); -``` - -Plus added `_handleFreeFormPrompt()` method (30 lines) that: -1. Validates interactive mode (free-form only in REPL) -2. Creates ReplHandler with session state -3. Executes prompt with streaming -4. Returns success/error - -**Impact:** The REPL loop (which already existed) now has something to DO when receiving free-form text. - ---- - -### 3. Task Tool Persistence (IMPROVED) ✅ - -**File:** `lib/src/tools/task_tool.dart` (177 → 270 lines) - -**Changes:** -- Added `_loadTasks()` — Loads tasks from `~/.clawd_code/tasks/*.json` -- Added `_saveTasks()` — Persists tasks to disk after create/update/stop -- Changed `_createTask()` → `async`, calls `_saveTasks()` -- Changed `_updateTask()` → `async`, calls `_saveTasks()` -- Changed `_stopTask()` → `async`, calls `_saveTasks()` -- Added `_getTasksDirectory()` — Centralized path logic - -**Before:** -- In-memory Map only -- Tasks lost on exit -- Not actually usable - -**After:** -- Tasks stored as JSON files on disk -- Survives CLI restart -- Can track background work across sessions -- Still doesn't spawn actual processes (noted as limitation) - -**Real or stubbed?** REAL for storage/tracking. Stubbed for process management (no sub-processes created, just metadata storage). - ---- - -### 4. API Client Vendor-Neutral Fix (CONTINUED) ✅ - -**File:** `lib/src/services/api_client.dart` (from prior pass) - -**Implemented:** -- Removed hardcoded `https://api.anthropic.com` default -- Now throws clear error if no URL configured -- Supports OPENROUTER_BASE_URL, ANTHROPIC_BASE_URL, CLAUDE_CODE_BASE_URL, API_BASE_URL - -**Impact:** Prevents silent fallback to Anthropic; forces explicit provider choice. - ---- - -## Real vs Stubbed: Honest Assessment - -| Component | Type | Status | -|-----------|------|--------| -| Free-form prompt → model | Real | ✅ Actually calls OpenRouter | -| Tool invocation | Real | ✅ BashTool, File tools execute | -| WebSearch/WebFetch | Real HTTP | ✅ Make actual OpenRouter calls | -| Conversation history | Real | ✅ Maintained in memory | -| Streaming responses | Real | ✅ Outputs deltas to stdout | -| Task persistence | Real | ✅ Files on disk | -| Task execution | Stubbed | ❌ No process spawning | -| MCP integration | Stubbed | ❌ 100% mock responses | -| Skill execution | Real-ish | ⚠️ Reads files, executes templates | -| Agent spawning | Stubbed | ❌ Fake responses | -| REPL | Real | ✅ Full interactive loop | -| Model integration | Real | ✅ Full tool loop | - ---- - -## Parity Progress: Before vs After - -| Area | Before | After | Gap | -|------|--------|-------|-----| -| **Core Execution** | 0% | 90% | Model works, tool loop works, REPL interactive | -| **Free-form prompts** | 0% | 100% | Now fully wired | -| **Task management** | 5% | 60% | Storage works, execution stubbed | -| **Tool availability** | 40% | 85% | Core tools + web tools + shell | -| **Vendor-neutral** | 50% | 85% | Anthropic defaults removed | -| **API integration** | 0% | 70% | OpenRouter wired, model calls real | -| **REPL interactivity** | 30% | 100% | Full loop now works | -| **Cost tracking** | 40% | 80% | Tracking integrated into model calls | - -**Weighted parity estimate:** -- Before: 33% (core tools only) -- After: 55-60% (full model loop + tools) - ---- - -## How to Test the New Functionality - -### 1. Start REPL with no arguments -```bash -clawd_code -``` -You'll see: `clawd> ` - -### 2. Set your API key (one of): -```bash -export OPENROUTER_API_KEY="sk-..." -# OR -export ANTHROPIC_API_KEY="sk-..." -``` - -### 3. Ask a free-form question -``` -clawd> How do I write a Dart CLI app? -``` - -**Expected behavior:** -1. Prompt gets tokenized as free-form (not a command) -2. ReplHandler.executePrompt() called -3. ToolLoopService.runTurn() invokes OpenRouter model -4. Model responds with answer and/or tool calls (bash, read file, etc.) -5. Tools execute -6. Model gets tool results -7. Final answer returned -8. Cost tracked and stored - -### 4. Try a web search -``` -clawd> Search for the latest Dart language features -``` - -**Expected behavior:** -- Model calls WebSearch tool (if OpenRouter API key has web search feature) -- WebSearch makes OpenRouter API call -- Results returned to model -- Model synthesizes answer - ---- - -## Remaining Work for Full Parity - -| Priority | Gap | Effort | Impact | -|----------|-----|--------|--------| -| **High** | Real task execution (process spawning) | High | Can't run background commands | -| **High** | Real MCP protocol (not mocked) | Very High | Can't connect to external services | -| **High** | Real agent spawning (not mocked) | High | Can't delegate to sub-agents | -| **Medium** | Skill execution engine (not template-only) | Medium | Skills are template substitution only | -| **Medium** | Complete 25 ported commands | Medium | Some commands not wired | -| **Low** | Daemon mode (ps, logs, attach, kill) | Medium | Process management features | -| **Low** | Team/collaborative features | Very High | Multi-agent coordination | -| **Low** | Browser/UI integration | High | Full Claude Code desktop experience | - ---- - -## Architecture Rule Verification - -**Rule:** "Anthropic umbilical severed, capability shape preserved" - -| Rule | Status | Evidence | -|------|--------|----------| -| No Anthropic-only path | ✅ | API selection supports OpenRouter, env flags control behavior | -| Vendor-neutral abstractions | ✅ | `kHostEndpoint`, `ApiProvider` enum, settings-driven model selection | -| Local-first behavior | ✅ | Works without backend (local tools, OpenRouter API only needs key) | -| Future SaaS-ready | ✅ | `kHostEndpoint` can point to custom backend when ready | -| Works without backend | ✅ | Model calls go to OpenRouter (external), not internal backend | - -**Verdict:** ✅ Architecture rules maintained - ---- - -## Code Quality Notes - -**What's good:** -- REPL handler is focused and single-responsibility -- Tool persistence is simple and reliable (JSON files) -- Cost tracking integrated properly -- No hardcoded vendor assumptions -- Error messages are clear and actionable - -**What could be improved:** -- ToolLoopService has debug print statements (lines 154, 164, 172) — remove in production -- ReplHandler could have configurable streaming vs batched modes -- Task tool doesn't validate JSON before loading (just skips bad files — acceptable for robustness) - -**Known limitations:** -- No actual task process spawning (noted clearly in code) -- No real MCP protocol (marked as "simulated") -- No real agent coordination (marked as "fake") -- WebSearch/WebFetch require OpenRouter API key with web access (expected) - ---- - -## Migration Status Summary - -**From the start:** -``` -Command System: Partial ▓░░ (73 of 98+ commands) -Tool System: Partial ▓░░ (core tools work, web tools real, advanced stubbed) -REPL/Interactive: Missing ░░░ → NOW COMPLETE ▓▓▓ -Model Integration: Missing ░░░ → NOW COMPLETE ▓▓▓ -API Integration: Missing ░░░ → NOW WORKING ▓▓░ -Task Management: Stubbed ░▓░ → NOW PERSISTENT ▓░░ -WebSearch/Fetch: Real ▓▓░ (wired into loop now) -Permissions: Real ▓▓▓ (was already complete) -Cost Tracking: Partial ▓░░ → NOW INTEGRATED ▓▓░ -``` - -**Overall parity:** -- Lines of code: ~40% (lots of skeleton remains, but critical path complete) -- Functional capability: 55-60% (can use interactive mode, model calls work, tools execute) -- Vendor-neutral: 85% (defaults removed, multi-provider ready) - ---- - -## Files Modified/Created - -### Created (new functionality): -- ✅ `lib/src/chat/repl_handler.dart` (106 lines) - -### Modified (wiring + fixes): -- ✅ `lib/src/app.dart` (added import + _handleFreeFormPrompt + 1 wiring line) -- ✅ `lib/src/tools/task_tool.dart` (persistence: +90 lines of actual code) -- ✅ `lib/src/services/api_client.dart` (vendor-neutral defaults) - -### Deleted (contradictory reports): -- ~~PARITY_REPORT.md~~ -- ~~IMPLEMENTATION_SUMMARY.md~~ (old version) -- ~~BRUTALLY_HONEST_PARITY_REPORT.md~~ -- ~~parity_review.md~~ -- ~~CORRECTIVE_PASS_SUMMARY.md~~ - -### Documentation (this pass): -- ✅ `MIGRATION_COMPLETION_REPORT.md` (this file) - ---- - -## How Model Integration Works End-to-End - -``` -User types: "Make a web server in Go" - ↓ -REPL loop reads input (app.dart line 859) - ↓ -_tokenize() → ["Make", "a", "web", "server", "in", "Go"] - ↓ -_dispatchTokens() called with surface=topLevel, interactive=true - ↓ -First token "Make" checked against command catalog - ↓ -Not found → _handleFreeFormPrompt() called (line 688) - ↓ -ReplHandler.executePrompt() created and called (repl_handler.dart:29) - ↓ -API key resolved: OPENROUTER_API_KEY or ANTHROPIC_API_KEY - ↓ -Model selected: settings.model or environment flags - ↓ -OpenRouterClient created (openrouter_client.dart) - ↓ -ToolLoopService.runTurn() invoked (tool_loop_service.dart:54) - ↓ -System prompt + tool definitions sent to model (line 79-80) - ↓ -Model receives: "Make a web server in Go" - ↓ -Model generates response with tool calls (e.g., "I'll create a Go server") - ↓ -Tool loop: extract tool uses (line 93) - ↓ -For each tool call: - - _normalizeToolInput() adds API keys, permissions (line 178-228) - - _executeTool() dispatches to ToolRegistry (line 148-176) - - Tool executes (BashTool creates files, GrepTool searches, etc.) - - Result sent back to model - ↓ -Loop continues until model stops using tools - ↓ -Final response returned to user - ↓ -Cost calculated and added to session (repl_handler.dart:88-103) - ↓ -User sees streamed response in real-time - ↓ -Conversation maintained in _conversationHistory for next prompt -``` - ---- - -## Next Steps for Full Parity - -To reach 80%+ parity: -1. Implement real task process spawning (ExecuteTask tool) -2. Implement real MCP protocol client (no mocking) -3. Implement real Agent spawning and coordination -4. Port remaining 25 commands -5. Add skill execution engine (not just template substitution) - -These are all medium-to-high effort but not blocking basic functionality. - ---- - -## How This Compares to old_repo - -**What old_repo had:** -- Interactive REPL ✅ (we have this now) -- Model calling tools ✅ (we have this now) -- Streaming responses ✅ (we have this now) -- Cost tracking ✅ (we have this now) -- Persistent tasks ✅ (we have this now) -- Multiple vendor support ✅ (we support it via settings/env) -- Free-form query support ✅ (we have this now) - -**What old_repo had that we don't yet:** -- Real task process spawning ❌ (we store metadata only) -- Real MCP servers ❌ (we mock) -- Real agents ❌ (we mock) -- Desktop UI ❌ (this is CLI only) -- All 98 commands ❌ (we have 73+) -- Team features ❌ (not implemented) - -**What we do differently:** -- Vendor-neutral first (not Anthropic-first) -- OpenRouter as preferred vendor (not Anthropic) -- Pure Dart/CLI (not TypeScript/React) -- Local-first architecture - ---- - -## Conclusion - -This migration pass moved from "partial framework" to "working interactive tool." The app can now: - -1. ✅ Accept free-form queries from users -2. ✅ Send them to a real LLM (OpenRouter or Anthropic) -3. ✅ Let the model invoke tools (bash, file ops, web search, etc.) -4. ✅ Execute those tools and return results -5. ✅ Stream responses back to the user -6. ✅ Track costs and maintain conversation history -7. ✅ Support multiple vendors (not Anthropic-only) -8. ✅ Work without a backend (local CLI + public APIs) - -**Parity with old_repo is now 55-60%** (was 33% at audit start). The framework is no longer a skeleton — it's a working product. - -The remaining 40% is mostly advanced features (real MCP, real agents, more commands) that don't block basic use. - ---- - -**Migration status: FUNCTIONAL** ✅ diff --git a/docs/legacy/MIGRATION_STATUS.md b/docs/legacy/MIGRATION_STATUS.md deleted file mode 100644 index 6b5f54e..0000000 --- a/docs/legacy/MIGRATION_STATUS.md +++ /dev/null @@ -1,457 +0,0 @@ -# Migration Status - -This repository has been converted from a Flutter starter into a Dart CLI -workspace, but the full legacy implementation in `old_repo/` is not yet ported. - -## Legacy Scope - -- Source files in `old_repo/`: 1902 -- Known slash commands: 98 -- Reserved top-level legacy entrypoints: 14 -- Command-related files under `old_repo/commands/`: 129 -- High-friction framework/dependency import matches: 2283 - -## Largest Legacy Areas - -- `utils`: 564 files -- `components`: 389 files -- `commands`: 207 files -- `tools`: 184 files -- `services`: 130 files -- `hooks`: 104 files -- `ink`: 96 files -- `bridge`: 31 files - -## What Is Ported - -**Core CLI** -- Dart package and executable layout -- top-level CLI bootstrap + REPL shell with command history -- Persisted settings, runtime state, auth metadata, command-usage stats -- 73 slash commands fully implemented (out of 98 total) - -**Tools & Execution** (`lib/src/tools/`) -- `BashTool`, `FileReadTool`, `FileWriteTool`, `FileEditTool` -- `BaseTool` abstract base class + `ToolRegistry` for registration/dispatch -- Full Bash execution with Process API, timeout support - -**Session & History** (`lib/src/session/`) -- `Message`, `ConversationSession`, `SessionSummary` models -- `SessionStore` singleton: saveSession, loadSession, listSessions, deleteSession, findSessionByName -- `ConversationHistory` in-memory manager with JSON/text export - -**Network & API** (`lib/src/api/`) -- `AnthropicClient` with full HTTP requests (createMessage, listModels, countTokens) -- `MessageRequestBuilder` for request construction -- `ResponseParser`, `ErrorParser` for response handling -- OAuth token integration via `oauth_service.dart` - -**Configuration & State** (`lib/src/local_state.dart`, `lib/src/runtime_state.dart`) -- `LocalSettings`: theme, editor, model, permissions, hooks, keybindings, MCP servers, plugins -- `RuntimeState`: auth metadata, command usage stats, statusline prompt -- Persistent JSON serialization to ~/.claude/ - -**Infrastructure Subsystems** -- Bridge subsystem (`lib/src/bridge/`): Unix socket comms, JSON-RPC protocol, message framing -- Daemon subsystem (`lib/src/daemon/`): SessionRecord, DaemonState, ProcessInfo, SessionStatus -- MCP subsystem (`lib/src/mcp/`): McpClient with stdio transport, JSON-RPC 2.0, tool dispatch -- Hooks subsystem (`lib/src/hooks/`): 26 hook kinds, HookCommand hierarchy (Bash/Prompt/Http/Agent), HookRunner with execution -- Analytics subsystem (`lib/src/analytics/`): AnalyticsEvent, AnalyticsService with JSONL logging -- Migrations subsystem (`lib/src/migrations/`): 9+ migration functions -- Skills subsystem (`lib/src/skills/`): 7 built-in skills + dynamic loader, skill registry - -**Utilities** (`lib/src/utils/`) -- 31 utility modules: formatters, cost/pricing, token counting, path helpers, git/worktree, slug generation, ANSI, memoization, set operations, semver, XML escaping, CLI args, UUIDs, circular buffers, etc. - -**Context & Plugins** (`lib/src/context/`, `lib/src/plugins/`) -- `ContextManager`: token usage tracking, available-token computation -- `TokenCounter`: character-based token estimation -- `PluginManager`: plugin discovery, enable/disable, component aggregation - -**Keybindings & Cost Tracking** (`lib/src/keybindings/`, `lib/src/services/`) -- `KeyBinding` model, `keybindings_loader` from ~/.claude/keybindings.json -- `CostTracker`: per-model/per-session token + cost accumulation -- Persistent cost state across sessions - -**Ported Commands (73 total)** -- Configuration: `help`, `status`, `version`, `config`, `vim`, `theme`, `effort`, `plan`, `color`, `output-style`, `fast`, `cost`, `doctor`, `init` -- Authentication: `login`, `logout`, `model`, `permissions` -- Session: `stats`, `statusline`, `upgrade`, `usage`, `tag`, `env`, `files`, `branch`, `export`, `memory`, `diff`, `rename`, `copy`, `keybindings`, `add-dir` -- Features: `brief`, `context`, `compact`, `resume`, `review`, `hooks`, `privacy-settings`, `release-notes`, `feedback` -- Tools: `pr-comments`, `commit`, `lint` -- Subsystems: `mcp`, `advisor`, `bughunter`, `terminal-setup`, `install-github-app`, `desktop`, `mobile`, `chrome`, `ide`, `agents`, `tasks`, `stickers`, `voice`, `btw`, `rewind`, `plugin`, `session`, `skills`, `commit-push-pr`, `init-verifiers`, `security-review` -- Session management: `ps`, `logs`, `attach`, `kill` - `daemon_manager.dart` (start/stop/list background sessions, log streaming, pid tracking, JSON registry under ~/.claude/sessions/) -- `SessionState` extended with `sessionTag`, `sessionName`, `additionalDirectories`, `briefModeEnabled`, `bughunterMode`, and `advisorModel` -- `LocalSettings` extended with `hooks`, `telemetry`, `privacyLevel`, `advisorModel`, and `mcpServers` fields -- legacy command inventory and reserved entrypoint inventory - -## Why The Full Port Is Not Finished - -- The old implementation is a Bun/TypeScript/React/Ink application, not a - small scriptable CLI. -- The runtime includes bridge, daemon, remote, MCP, auth, analytics, and - tool-execution systems that require behavior-level porting. -- A true 1:1 Dart migration requires replacing the legacy runtime, not wrapping - it or generating placeholders. - -## Current Direction - -The current Dart codebase now covers 56 migrated commands and a persistent CLI -state layer, so the remaining migration can proceed subsystem by subsystem from -an actual working Dart shell instead of a starter scaffold. - -### Last Completed Slice (2026-04-01, ninth pass — Hooks runtime system) - -**Hooks system fully ported:** -- `lib/src/hooks/hook_types.dart` — `HookKind` enum with 26 hook event types (PreToolUse, PostToolUse, PostToolUseFailure, PermissionDenied, Notification, UserPromptSubmit, SessionStart, SessionEnd, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, PermissionRequest, Setup, TeammateIdle, TaskCreated, TaskCompleted, Elicitation, ElicitationResult, ConfigChange, InstructionsLoaded, WorktreeCreate, WorktreeRemove, CwdChanged, FileChanged). Sealed `HookCommand` hierarchy with `BashCommandHook`, `PromptHook`, `HttpHook`, `AgentHook` subclasses. `HookSpec` model (kind, command, target) with `getDisplayText()`. -- `lib/src/hooks/hook_context.dart` — `HookContext` model (kind, targetName, input, output, exitCode, environment, metadata) with `toJsonString()` for passing to shell commands. `HookResult` model for capturing hook execution results (success, stdout, stderr, exitCode, shouldContinue, message, hookOutput) with JSON parsing via `fromJson()`. -- `lib/src/hooks/hook_loader.dart` — `HookLoader` static class that loads hooks from `~/.claude/hooks.json` or `~/.claude/hooks.yaml` (basic YAML parser for simple cases). Parses into `HookSpec` list. Supports all hook command types and condition filtering. -- `lib/src/hooks/hook_runner.dart` — `HookRunner` class with `runHooksForKind()` method that filters hooks by kind/target, evaluates conditions, executes bash/HTTP/prompt/agent hooks. Supports timeouts (default 10min, overridable per hook). Bash hooks run with hook context in environment. HTTP hooks POST context as JSON. Returns list of `HookResult`. - -**Wired into app.dart:** -- `runClawdCode()` calls `HookLoader.loadHooks()` during startup -- `_ClawdCli` constructor accepts `HookRunner` parameter -- `_execute()` method calls `hookRunner.runHooksForKind()` before command execution (UserPromptSubmit hook with command input) and after command execution (Stop hook with exit code) -- Hook output is logged appropriately; blocking hooks (shouldContinue: false) stop command processing - -**Verified:** `dart analyze` — zero errors in hooks/ files (pre-existing errors in other modules unrelated) - -### Last Completed Slice (2026-04-01, eighth pass — Session storage and Conversation history) - -**Session storage and conversation history fully ported:** -- `lib/src/session/session_types.dart` — Complete models: `Message` (role, content, timestamp, tokens); `ConversationSession` (id, name, messages list, created/updated timestamps, optional cost in USD, optional model name); `SessionSummary` (lightweight listing summary without messages). All with JSON serialization. -- `lib/src/session/session_store.dart` — `SessionStore` singleton with: - - `saveSession(ConversationSession)` — persists to `~/.clawd_code/sessions/{id}.json` - - `loadSession(String id)` — loads full session from disk - - `listSessions()` — returns all sessions as summaries, sorted newest-first - - `deleteSession(String id)` — removes session file - - `findSessionByName(String name)` — case-insensitive name lookup -- `lib/src/session/conversation_history.dart` — `ConversationHistory` in-memory manager: - - `setSession(ConversationSession)` — loads session into memory - - `getMessages()` — returns all messages in current session - - `addMessage(String role, String content, int? tokens)` — appends message and updates timestamp - - `clear()` — empties messages but preserves session metadata - - `exportToText()` — plain-text export with headers - - `exportToJson()` — JSON export via SessionStore schema - -**Wired into commands (`lib/src/app.dart`):** -- `/branch` — forks current session to new ID with new name, saves fork, loads it -- `/export` — exports to JSON or plain text, supports stdout -- `/rename` — renames session, persists to disk if active -- `/copy` — copies last assistant message -- `/resume` — lists saved sessions by name/id, loads on exact match - -**Added `_makeSessionId()` helper:** -- Uses `generateUuid()` from `utils/uuid_utils.dart` for session ID generation - -**Verified:** `dart analyze` — zero errors after adding uuid_utils import and _makeSessionId function - -## Resume Point - -If another Claude picks this up, start from the current Dart CLI runtime in -`lib/src/app.dart`, `lib/src/local_state.dart`, and `lib/src/runtime_state.dart`. -Those files now contain the migrated command loop, persisted settings, local -auth metadata, permission rules, statusline prompt storage, command-usage -stats, and session storage integration. - -### Last Completed Slice (2026-04-01, eleventh pass — Context Window & Plugin System) - -**Context Window Management (`lib/src/context/`):** -- `context_types.dart` — `ContextWindow` model: tracks currentTokens, maxTokens, usage breakdown (system, messages, tools, files), computed availableTokens/percentageUsed, flags (isNearCapacity, isCritical) -- `token_counter.dart` — Character-based token counting (4 chars/token heuristic): `countTokensInString()`, `countTokensInJson()`, `countTokensInContentBlock()` (handles text, tool_use, tool_result, image, thinking), `countTokensInMessage()`, `countTokensInMessages()`, `countTokensForContent()` -- `context_manager.dart` — `ContextManager` singleton: manages session token accounting across components. API: `getCurrentState()`, `getAvailableTokens()`, `getPercentageUsed()`, `addSystemContext()`, `addMessage()`, `addMessages()`, `addToolDefinition()`, `addFile()`, `removeMessageTokens()`, `removeFileTokens()`, `estimateTokens()`, `estimateMessageTokens()`, `getContextBreakdown()`, `getComponentHistory()`, `reset()`, `resetComponent()`, status queries `isNearCapacity()`, `isAtWarningLevel()`, `isCritical()` - -**Plugin System (`lib/src/plugins/`):** -- `plugin_types.dart` — `Plugin` model (name, version, description, author, entrypoint, permissions, config, paths for commands/agents/skills/hooks, mcp servers). `PluginAuthor` (name, email, url). `LoadedPlugin` (plugin + path, source, repository, enabled/disabled, builtin flag, SHA, path aggregation methods). `PluginLoadResult` (enabled[], disabled[], errors[], all[], totalCount, isSuccess). `PluginError` (code, message, pluginName?, details?) -- `plugin_loader.dart` — Plugin discovery from `~/.claude/plugins/` and TODO project `.claude/plugins/`. `loadAllPlugins()` async returns `PluginLoadResult`. `_loadPluginsFromDirectory()` reads plugin directories, loads `plugin.json`/`manifest.json` manifests with validation. Helper functions: `findPlugin()`, `findPluginsBySource()`, `getEnabledPlugins()`, `getDisabledPlugins()` -- `plugin_manager.dart` — `PluginManager` singleton for plugin lifecycle. API: `initialize(loadResult)`, accessors `all`, `enabled`, `disabled`, `count`, `getPlugin(name)`, `hasPlugin()`, `isPluginEnabled()`, `enablePlugin()`, `disablePlugin()`, `togglePlugin()`. Path aggregation: `getAllCommandPaths()`, `getAllAgentPaths()`, `getAllSkillPaths()`, `getAllMcpServers()`, `getAllHooksConfig()`. Queries: `getPluginsBySource()`, `getPluginsRequiringPermission()`, `getEnabledPluginsRequiringPermission()`, `getPluginInfo()`, `getAllPluginInfo()`. Lifecycle: `reset()`, `reload()` (stub). Execution: `executePlugin()` (TODO: requires sandboxing implementation). Global instance: `getGlobalPluginManager()`, `initializePluginManager()` - -**Verified:** `dart analyze` — new context/plugins files compile without errors. Token counter uses safe type checks for Map. Plugin loader handles missing manifests gracefully. Manager's global instance uses null-coalescing assignment. - -### Previous Slice (2026-04-01, tenth pass — Anthropic API client and SDK integration) - -**Anthropic API client and SDK types fully ported:** -- `lib/src/api/api_types.dart` — Core types: `StopReason` enum (endTurn, maxTokens, stopSequence, toolUse), `ContentBlockType` enum, `TextBlock` class (immutable, JSON round-trip), `ToolUse` class (id, type, name, input), `ToolResult` class (for API input), `TextContent` class, `ApiMessage` class (full response with id, role, content, model, stopReason, usage, token counts), `MessageRequest` class (builder input). All with `fromJson()` and `toJson()` factories/methods. -- `lib/src/api/request_builder.dart` — Request building helpers: `MessageRequestBuilder` (fluent API: withSystem, withTemperature, withTools, withToolChoice, withMetadata), `HeaderBuilder` (standard headers + custom parsing from env), `MessageBuilder` static helpers (createUserMessage, createAssistantMessage, createAssistantMessageWithToolUse, createToolResultContent). Normalization stubs for messages and content. -- `lib/src/api/response_parser.dart` — Response parsing: `ResponseParser` (parseMessageResponse, extractTextContent, extractToolUseBlocks, hasToolUse, didStopOnToolUse/maxTokens/endTurn), `ErrorParser` (error classification: isAuthenticationError, isRateLimitError, isPromptTooLongError, isMediaSizeError, with error detail parsing), `StreamingResponseParser` (stub for SSE stream parsing with support for message_delta and message_stop events). -- `lib/src/api/anthropic_client.dart` — Main `AnthropicClient` class: constructor with `AnthropicClientConfig`, public methods `createMessage()` (sends message, parses response), `listModels()`, `getModel(modelId)`, `countTokens()` (beta API). Internal HTTP layer using `dart:io.HttpClient` with proper error handling. Custom exception classes: `ApiException`, `AuthenticationException`, `RateLimitException`, `RequestTooLargeException`. `AnthropicClientFactory.create()` factory method with environment-based key/URL resolution and OAuth token support via `loadStoredTokens()`. - -**OAuth integration:** -- Client respects stored OAuth tokens from `loadStoredTokens()` (delegated to `oauth_service.dart`) -- Falls back to ANTHROPIC_API_KEY env var resolution chain -- Supports custom base URLs from ANTHROPIC_BASE_URL or CLAUDE_CODE_BASE_URL env vars - -**Error handling:** -- HTTP status codes mapped to specific exception types -- Error message extraction from API JSON error responses -- Prompt-too-long error parsing with token count extraction (regex-based) -- Media size error detection (image/PDF validation) -- Rate limit classification for rate-limiting logic - -**Verified:** `dart analyze` — zero errors in new API files. Fixed pre-existing error in lib/src/context/token_counter.dart (Map type assertion). - -### Last Completed Slice - -- Expanded migrated command surface from 35 to 44 commands -- Added `mcp` (list/add/remove/enable/disable MCP servers with settings persistence) -- Added `advisor` (set/unset advisor model, persists to settings) -- Added `bughunter` (session toggle; was disabled/hidden in legacy) -- Added `terminal-setup` (detects terminal, gives per-terminal binding instructions) -- Added `install-github-app` (shows docs URL + current repo hint via gh CLI) -- Added `desktop` / alias `app` (macOS/Windows only, explains handoff) -- Added `mobile` / aliases `ios`, `android` (shows store links) -- Added `chrome` (shows extension + permissions URLs) -- Added `ide` (detects IDE from env, shows install hint) -- Extended `LocalSettings` with `advisorModel` and `mcpServers` fields -- Extended `SessionState` with `bughunterMode` and `advisorModel` fields - -### Last Completed Slice (2026-04-01, seventh pass — Migrations system + Skills system) - -**Migrations (`lib/src/migrations/`):** -- `migration_types.dart` — `Migration` model (id, description, up fn) and `MigrationRecord` (id, completedAt, JSON round-trip) -- `migration_runner.dart` — reads `~/.claude/migration_state.json`, runs pending migrations in order, marks them complete. Ported all migration logic from `old_repo/migrations/`: replBridgeEnabled rename, autoUpdates→settings, bypassPermissionsAccepted→settings, fennec→opus alias remap, sonnet1m→sonnet45 pin, sonnet45→sonnet46 unpinning, legacyOpus4.0/4.1→opus alias. `allMigrations` exposes the ordered list. - -**Skills (`lib/src/skills/`):** -- `skill_types.dart` — `Skill` model (name, description, source, promptTemplate, allowedTools, aliases, model, disableModelInvocation), `SkillSource` enum (bundled/user/project/mcp), `SkillFrontmatter` for parsing disk-based skill files. `Skill.resolvePrompt(args)` handles argument injection. -- `skill_loader.dart` — `loadSkillsFromDir()` discovers skill dirs (`/SKILL.md`) and standalone `.md` files; `loadUserSkills()` reads `~/.claude/skills/`; `loadProjectSkills()` reads `.claude/skills/` in cwd. Minimal YAML frontmatter parser covers all common fields. -- `skill_registry.dart` — `SkillRegistry` singleton with `register()`, `lookup()`, `all`, `mergeExternalSkills()`. `registerBundledSkills()` registers 7 built-in skills ported from `old_repo/skills/bundled/`: `update-config`, `keybindings-help`, `simplify`, `debug`, `remember`, `skillify`, `stuck`. `loadAndMergeExternalSkills()` loads and merges user+project skills. - -**Verified:** `dart analyze` — zero errors in new files - -### Last Completed Slice (2026-04-01, sixth pass — Analytics, Cost tracking, Keybindings) - -**Analytics:** -- `lib/src/analytics/analytics_types.dart` — `AnalyticsEvent` model, `AnalyticsMetadata` typedef, `AnalyticsEventKind` enum -- `lib/src/analytics/analytics_service.dart` — `logAnalyticsEvent()`, `logAnalyticsEventAsync()`, event queue (drains on `initAnalytics()`), flush to `~/.claude/analytics.jsonl`, respects `isAnalyticsDisabled()`. HTTP reporting is a TODO stub. - -**Cost tracking wired into REPL:** -- `/cost` command now calls `costTracker.formatTotalCost()` — real per-model breakdown instead of placeholder zeros -- `_persistCostState()` called on all REPL exit paths. Writes `~/.claude/last_session_cost.json`. - -**Keybindings:** -- `lib/src/keybindings/keybindings_types.dart` — `KeyContext` enum (18 contexts), `KeyBinding` model -- `lib/src/keybindings/keybindings_loader.dart` — `loadKeybindings()`, `resolveKeybinding()` (context-then-global priority) -- REPL wires keybindings on each turn: `command:foo` dispatches `/foo`, `app:exit` exits - -**Verified:** `dart analyze` — zero errors in `lib/src/` - -### Last Completed Slice (2026-04-01, fifth pass — QueryEngine + Task layer) - -**Query engine & task execution ported:** -- `lib/src/query_engine.dart` — QueryEngine class: manages core query lifecycle + session state, message history, permission tracking, system prompt building. Stub network path (TODO). Types: SdkMessage, SdkResultMessage, QueryEngineConfig, PermissionDenial, SlashCommandResult -- `lib/src/tasks/task_runner.dart` — TaskRunner: spawns shell/agent tasks, stop task logic with error handling (StopTaskError), background task listing. Functions: getPillLabel (display text for active tasks) -- `lib/src/coordinator/coordinator_mode.dart` — Coordinator mode utilities: isCoordinatorMode(), getCoordinatorUserContext(), getCoordinatorSystemPrompt() + workerToolContext injection. Matches old_repo/coordinator/coordinatorMode.ts -- `lib/src/utils/env_utils.dart` — Environment utilities: isEnvTruthy(), isEnvDefinedFalsy(), getClaudeConfigHomeDir(), getTeamsDir() - -**Verified:** `dart analyze` — zero errors in new ported files (minor warnings acceptable: unused imports in coordinator_mode, query_engine; unused field in query_engine; unnecessary cast in task_manager) - -### Last Completed Slice (2026-04-01, third pass — constants/types/services layer) - -**Constants added to `lib/src/constants/`:** -- `xml.dart` — all XML tag name constants (command, bash, task notification, teammate, fork, etc.) -- `spinner_verbs.dart` — full `spinnerVerbs` list + `turnCompletionVerbs` -- `oauth.dart` — `OauthConfig`, `getOauthConfig()`, scope lists, `allOauthScopes` -- `files.dart` — `binaryExtensions`, `hasBinaryExtension()`, `isBinaryContent()` - -**Services added to `lib/src/services/`:** -- `cost_tracker.dart` — full session-level cost/token accumulation (`addToTotalSessionCost`, `formatTotalCost`, restore/reset helpers, per-model usage map) -- `api_client.dart` — `ApiProvider` enum, `resolveApiKey()`, `resolveBaseUrl()`, `getApiProvider()`; network methods stubbed with TODOs -- `oauth_service.dart` — `OauthTokens` model, `oauthTokenFilePath()`; browser/HTTP methods stubbed with TODOs - -**Verified:** `dart analyze` — zero errors - -### Last Completed Slice (2026-04-01, second pass) - -- Expanded migrated command surface from 53 to 56 commands - - `commit-push-pr` — shows current git state, explains workflow - - `init-verifiers` — explains verifier skill types and limitations - - `security-review` — shows diff stat, explains AI security analysis workflow -- Ported 14 new utility modules from old_repo/utils/ (see above) - -### Previous Slice (2026-04-01) - -- Expanded migrated command surface from 44 to 53 commands -- Added `agents` (stub - requires live REPL tool permission context) -- Added `tasks` / alias `bashes` (stub - requires live background task list) -- Added `stickers` (opens browser to stickermule URL, fallback prints URL) -- Added `voice` (stub - voice mode requires Claude.ai account + REPL session) -- Added `btw` (stub - side question mode requires live model session) -- Added `rewind` / alias `checkpoint` (stub - requires REPL session history) -- Added `plugin` / aliases `plugins`, `marketplace` (subcommand dispatch + help) -- Added `session` / alias `remote` (stub - remote mode not available in Dart CLI) -- Added `skills` (stub - explains skills directory convention) -- Created `lib/src/utils/` directory with 5 ported utility modules: - - `array_utils.dart` - intersperse, countWhere, uniq - - `string_utils.dart` - escapeRegExp, capitalize, plural, firstLineOf, countChar, truncate - - `slash_command_parsing.dart` - parseSlashCommand - - `word_slug.dart` - generateWordSlug, generateShortWordSlug - - `tagged_id.dart` - convertToTaggedId (base58-encoded tagged IDs) - - `uuid_utils.dart` - validateUuid, generateUuid, createAgentId - -### New Utility Modules (2026-04-01) - -- `lib/src/utils/xml_utils.dart` — escapeXml, escapeXmlAttr (from old_repo/utils/xml.ts) -- `lib/src/utils/sleep_utils.dart` — sleep (with CancelToken), withTimeout (from old_repo/utils/sleep.ts) -- `lib/src/utils/xdg_dirs.dart` — getXdgStateHome, getXdgCacheHome, getXdgDataHome, getUserBinDir (from old_repo/utils/xdg.ts) -- `lib/src/utils/tempfile_utils.dart` — generateTempFilePath (from old_repo/utils/tempfile.ts) -- `lib/src/utils/timeout_constants.dart` — getDefaultBashTimeout, getMaxBashTimeout (from old_repo/utils/timeouts.ts) -- `lib/src/utils/cli_args.dart` — eagerParseCliFlag, extractArgsAfterDoubleDash (from old_repo/utils/cliArgs.ts) -- `lib/src/utils/agent_id.dart` — formatAgentId, parseAgentId, generateRequestId, parseRequestId (from old_repo/utils/agentId.ts) -- `lib/src/utils/circular_buffer.dart` — CircularBuffer (from old_repo/utils/CircularBuffer.ts) -- `lib/src/utils/system_directories.dart` — getSystemDirectories (from old_repo/utils/systemDirectories.ts) -- `lib/src/utils/argument_substitution.dart` — parseArguments, parseArgumentNames, generateProgressiveArgumentHint, substituteArguments (from old_repo/utils/argumentSubstitution.ts) -- `lib/src/utils/worktree_mode.dart` — isWorktreeModeEnabled (from old_repo/utils/worktreeModeEnabled.ts) -- `lib/src/utils/worktree_utils.dart` — validateWorktreeSlug, worktreeBranchName, worktreePathFor, parsePrReference, isTmuxAvailable (from old_repo/utils/worktree.ts) -- `lib/src/utils/which.dart` — which, whichSync (from old_repo/utils/which.ts) -- `lib/src/utils/treeify.dart` — treeify (from old_repo/utils/treeify.ts) - -### New Utility Modules (2026-04-01, third pass) - -Ported 9 additional self-contained utility modules from `old_repo/utils/`: - -- `lib/src/utils/format_utils.dart` — formatFileSize, formatSecondsShort, formatDuration, formatNumber, formatTokens, formatRelativeTime, formatRelativeTimeAgo, formatLogMetadata, formatBriefTimestamp (from format.ts + formatBriefTimestamp.ts) -- `lib/src/utils/hash_utils.dart` — djb2Hash, hashContent, hashPair (from hash.ts) -- `lib/src/utils/memoize_utils.dart` — MemoizedWithTTL, MemoizedWithTTLAsync, LruCache, MemoizedWithLRU (from memoize.ts, no lru-cache dep) -- `lib/src/utils/semver_utils.dart` — semverOrder, semverGt, semverGte, semverLt, semverLte, semverSatisfies (from semver.ts, pure Dart) -- `lib/src/utils/errors_utils.dart` — ClaudeError, MalformedCommandError, AbortError, ConfigParseError, ShellError, isAbortError, errorMessage, toException (from errors.ts, SDK-free subset) -- `lib/src/utils/set_utils.dart` — setDifference, setIntersects, setEvery, setUnion (from set.ts) -- `lib/src/utils/sanitization_utils.dart` — partiallySanitizeUnicode, recursivelySanitizeUnicode (from sanitization.ts) -- `lib/src/utils/sequential_utils.dart` — Sequential, makeSequential (from sequential.ts) -- `lib/src/utils/group_by_utils.dart` — groupBy, groupByKey (from objectGroupBy.ts) -- `lib/src/utils/model_cost.dart` — ModelCosts, all cost tier constants, calculateUSDCost, getModelCosts, formatModelPricing, getModelPricingString (from modelCost.ts, without analytics/bootstrap deps) -- `lib/src/utils/path_utils.dart` — expandPath, toRelativePath, containsPathTraversal, normalizePathForConfigKey (from path.ts, without Windows-specific and fsOperations deps) - -Previously skipped — now ported with pure Dart (2026-04-01, fifth pass): -- `tokens.ts` → `lib/src/utils/token_utils.dart` — char-based heuristics, TokenUsageRecord, estimateTokensFromMessages -- `diff.ts` → `lib/src/utils/diff_utils.dart` — LCS-based line diff, DiffHunk, getPatchFromContents, countLinesChanged, formatPatch -- `truncate.ts` → `lib/src/utils/truncate_utils.dart` — truncateToWidth, truncateStartToWidth, truncatePathMiddle, truncate, wrapText -- `glob.ts` → `lib/src/utils/glob_utils.dart` — pure Dart pattern matching, globToRegex, matchesGlob, glob() -- `json.ts` → `lib/src/utils/json_utils.dart` — safeParseJson, parseJsonl, jsonStringify, addItemToJsonArray - -### Last Completed Slice (2026-04-01, fifth pass — remaining utils + unit tests) - -- Ported 5 previously-skipped utils with pure Dart: - - `token_utils.dart` — already existed, verified complete - - `diff_utils.dart` — already existed, verified complete - - `truncate_utils.dart` — already existed, verified complete - - `glob_utils.dart` — already existed, verified complete - - `json_utils.dart` — **new**: safeParseJson, parseJsonl, jsonStringify, addItemToJsonArray - -- Added `dev_dependencies: test: ^1.25.0` to pubspec.yaml - -- Wrote unit tests in `test/`: - - `test/utils/string_utils_test.dart` — escapeRegExp, capitalize, plural, firstLineOf, countChar, truncate - - `test/utils/format_utils_test.dart` — formatFileSize, formatSecondsShort, formatDuration, formatNumber, formatTokens - - `test/utils/semver_utils_test.dart` — semverOrder, semverGt, semverLt, semverSatisfies - - `test/utils/model_cost_test.dart` — getCanonicalModelName, getModelCosts, calculateUSDCost, formatModelPricing - - `test/utils/array_utils_test.dart` — intersperse, countWhere, uniq - - `test/tools/bash_tool_test.dart` — echo, multi-line, exit code, empty command, stderr - - `test/tools/file_read_tool_test.dart` — read file, missing file, offset/limit, empty file - -### Verified Before Stopping - -Run with a temporary HOME to avoid Dart telemetry/session-file permission noise: - -```bash -HOME=/tmp/clawd_code_home dart analyze -HOME=/tmp/clawd_code_home dart run bin/clawd_code.dart --help -printf '/status\n/model opus\n/permissions allow Bash(npm test)\n/login ben@example.com max default_claude_max_20x\n/usage\n/stats\n/statusline show\n/doctor\n/init preview\n/logout\n' | HOME=/tmp/clawd_code_home dart run bin/clawd_code.dart -printf '/login ben@example.com max default_claude_max_20x\n/upgrade\n/permissions allow Read(~/**)\n/permissions remove 1\n/permissions\n/model default\n/status\n/exit\n' | HOME=/tmp/clawd_code_home dart run bin/clawd_code.dart -``` - -Status at stop: - -- `dart analyze` was clean (no errors) -- REPL smoke tests passed -- Help output reported 53 ported commands (latest run) -- Remaining feasibly unported commands from old_repo/commands/: agents/tasks/stickers/voice/btw/rewind/plugin/session/skills now ported; remaining ones are React-heavy JSX UIs or stub-only (issue, share, onboarding, summary are `{isEnabled: false, isHidden: true}` stubs in old_repo too) - -### Environment Notes - -- This workspace is not currently a git repository -- `old_repo/` exists and is the source of truth for behavior -- `old_repo/` does not have a root `package.json` or `tsconfig.json`, so exact - runtime reproduction must be inferred from checked-in source rather than a - pinned manifest - -### Remaining Work (2026-04-02) - -**Ported in this session:** 73 slash commands (expanded from 24), all major subsystems except React/Ink UI. - -Still unported: - -- 25 slash commands (remaining unported at session end): - ant-trace, autofix-pr, backfill-sessions, break-cache, bridge-kick, ctx-viz, debug-tool-call, extra-usage, good-claude, heapdump, insights, migrate, new, list, reply, remote-control, sidekick, unprotect, waymark, and others -- React/Ink UI components (389 files) — not needed for CLI, but required for interactive terminal menus/dialogs -- Full Anthropic API streaming (request/response) — framework is in place, network I/O complete, but streaming not implemented -- Plugin execution/sandboxing (discovery/management done, execution is TODO) -- Permission rule evaluation (full syntax parsing — basic allow/deny/ask framework is wired) -- Some legacy entrypoint behaviors (bridging, remote sessions) - -**What is production-ready:** -- Core CLI with 73 commands -- Session storage and conversation history -- Tool execution (Bash, File I/O, Editing) -- MCP client (stdio-based server spawning + JSON-RPC) -- Bridge/Daemon (Unix socket comms) -- Hooks (execution engine + all hook types) -- Auth (local token persistence, oauth_service) -- Analytics (JSONL event logging) -- Migrations, skills, plugins (loading/management) -- Cost tracking (per-model/per-session) -- Context window (token counting and management) -- Anthropic API client (real HTTP requests, error handling) - -### Remaining Large Items - -If you want to resume porting: - -1. **UI for interactive commands** — Some commands like `/new`, `/list`, `/reply` would benefit from interactive terminal menus (ported UI logic exists in old_repo/ but requires Dart terminal library) -2. **Full API streaming** — Request/response streaming for the Message API (partially stubbed) -3. **Plugin execution** — Sandboxing/running user plugins (detection and loading done) -4. **Permission rules** — Full expression evaluation for allow/deny/ask rules -5. **Remaining 25 commands** — Most are advanced features or require above systems - -### Practical Status - -The Dart CLI is a **fully-functional, production-ready** implementation of the core Claude Code experience: -- All essential commands work -- Session storage and history work -- Tools execute correctly -- MCP servers can be connected and used -- Hooks fire appropriately -- Auth persists across sessions -- Settings are configurable - -The **only major missing piece is the React/Ink interactive UI** — the CLI works with plain text input/output, which is perfectly functional. - -- `mcp` -- `agents` -- `tasks` -- `review` -- `session` -- `resume` -- remote/bridge/daemon entrypoints - -### First Unported Commands - -At the time I stopped, the next unported slash commands shown by `/status` were: - -- `add-dir` -- `advisor` -- `agents` -- `ant-trace` -- `autofix-pr` -- `backfill-sessions` -- `branch` -- `break-cache` -- `bridge-kick` -- `brief` - -### Practical Handoff Note - -The current Dart CLI is honest about what is still missing: known-but-unported -commands fall through to the legacy inventory instead of disappearing. Keep that -pattern. The next step is not scaffolding; it is porting real behavior from -`old_repo/` into the existing Dart runtime one slice at a time. diff --git a/docs/legacy/PARITY_STATUS.md b/docs/legacy/PARITY_STATUS.md deleted file mode 100644 index c553238..0000000 --- a/docs/legacy/PARITY_STATUS.md +++ /dev/null @@ -1,326 +0,0 @@ -# Parity Status: Dart CLI vs TypeScript Reference - -**Last Updated:** 2026-04-04 -**Audit Method:** Fresh code inspection + implementation verification -**Confidence Level:** High (implementation complete, tested against specification) - ---- - -## Overall Parity: 55-60% - -This represents **functional parity** by critical path: -- User can open REPL and ask free-form questions ✅ -- Model processes them and calls tools ✅ -- Tools execute (bash, file ops, web search) ✅ -- Responses stream back in real-time ✅ -- Costs tracked and stored ✅ - ---- - -## Subsystem-by-Subsystem Status - -### 1. REPL & Interactive Mode — FULL PARITY ✅ - -| Component | Status | Evidence | -|-----------|--------|----------| -| Interactive prompt | ✅ Full | `app.dart` line 857-917: full REPL loop | -| Free-form prompts | ✅ Full | `repl_handler.dart`: routes to model | -| Streaming output | ✅ Full | `OpenRouterClient.createStreamingMessage()` | -| Keybindings | ✅ Partial | Loads from `~/.claude/keybindings.json` | -| Exit/quit handling | ✅ Full | Loop exits cleanly on ^D or `/exit` | -| Cost display | ✅ Partial | Tracked but not shown during execution | - -**Gap:** Cost display should show per-prompt, currently shows on exit only. - ---- - -### 2. Model Integration — FULL PARITY ✅ - -| Component | Status | Evidence | -|-----------|--------|----------| -| Model selection | ✅ Full | `/model` command, environment override, settings | -| API key resolution | ✅ Full | Checks settings + environment + fallback | -| Request construction | ✅ Full | `OpenRouterClient.createMessage()` with tools | -| Streaming responses | ✅ Full | Token-by-token streaming with callbacks | -| Token usage tracking | ✅ Full | Extracted from API response | -| Error handling | ✅ Full | Proper exception handling in tool loop | - -**Gap:** None identified. - ---- - -### 3. Tool System — FULL PARITY (core) ✅, PARTIAL (advanced) - -#### Core Tools — FULL PARITY -| Tool | Status | Notes | -|------|--------|-------| -| Bash | ✅ Full | Real subprocess execution | -| Read | ✅ Full | File I/O with line numbers | -| Write | ✅ Full | File creation/overwrite | -| Edit | ✅ Full | In-place file editing | -| Glob | ✅ Full | File pattern matching | -| Grep | ✅ Full | Regex file search | -| WebSearch | ✅ Full | OpenRouter web search API | -| WebFetch | ✅ Full | HTML parsing + OpenRouter summarization | - -#### Advanced Tools — PARTIAL/STUBBED -| Tool | Status | Notes | -|------|--------|-------| -| Task | ⚠️ Partial | Storage works, process spawning stubbed | -| Skill | ⚠️ Partial | Reads and templates, no execution engine | -| MCP | ❌ Stubbed | 100% mock responses | -| Agent | ❌ Stubbed | Fake spawning | - -**Honest assessment:** Core tools are production-ready. Advanced tools are stubs. - ---- - -### 4. Permissions System — FULL PARITY ✅ - -| Component | Status | Evidence | -|-----------|--------|----------| -| Permission modes (7) | ✅ Full | All implemented: acceptEdits, auto, bubble, etc. | -| Tool safety classification | ✅ Full | Assigned in `ToolRegistry` | -| Rule parsing | ✅ Full | Supports `domain:`, `Tool(args)` syntax | -| Integration with execution | ✅ Full | Checked before every tool call | - -**Gap:** None. - ---- - -### 5. API Client Layer — FULL PARITY ✅ - -| Component | Status | Evidence | -|-----------|--------|----------| -| OpenRouter support | ✅ Full | `OpenRouterClient` complete | -| Anthropic format support | ✅ Full | `ApiMessage.fromJson()` handles both | -| OpenAI-compatible format | ✅ Full | `ApiMessage.fromOpenRouterResponse()` | -| Vendor-neutral abstraction | ✅ Full | `ApiProvider` enum, no hardcoded defaults | -| Retry logic | ✅ Full | Exponential backoff in client | -| Error handling | ✅ Full | Proper exception types | - -**Gap:** None. - ---- - -### 6. Cost Tracking — FULL PARITY ✅ - -| Component | Status | Evidence | -|-----------|--------|----------| -| Per-call calculation | ✅ Full | `calculateUSDCost()` with per-model pricing | -| Session totals | ✅ Full | Aggregated in `costTracker` | -| Model breakdown | ✅ Full | Stored by model name | -| Persistence | ✅ Full | Saved to `~/.claude/last_session_cost.json` | -| Token tracking | ✅ Full | Input, output, cache, web requests | - -**Gap:** None. - ---- - -### 7. Command System — PARTIAL PARITY ⚠️ - -| Component | Status | Evidence | -|-----------|--------|----------| -| Command catalog | ✅ Full | `CommandCatalog` class with legacy lookup | -| Slash command parsing | ✅ Full | Leading `/` recognized, dispatched | -| 73 ported commands | ✅ Full | Listed in `app.dart` `_buildCatalog()` | -| 25+ unported commands | ❌ Missing | Legacy commands show "not ported" message | -| Help system | ✅ Full | `/help` command works | -| Command metadata | ✅ Full | Descriptions, aliases, legacy source tracking | - -**Gap:** 25+ commands not yet ported (reserved for future work). - ---- - -### 8. Data Persistence — PARTIAL PARITY ⚠️ - -| Component | Status | Evidence | -|-----------|--------|----------| -| Settings (JSON) | ✅ Full | `~/.clawd_code/settings.json` | -| Session history (in-memory) | ✅ Full | Conversation maintained during session | -| Tasks (JSON) | ✅ Full | `~/.clawd_code/tasks/*.json` (NEW) | -| Cost state | ✅ Full | `~/.claude/last_session_cost.json` | -| Keybindings | ✅ Full | `~/.claude/keybindings.json` | -| Session state | ⚠️ Partial | In-memory only, not persisted across restarts | - -**Gap:** Session history not saved between restarts (by design — each new session is fresh). - ---- - -### 9. Vendor-Neutral Design — FULL PARITY ✅ - -| Component | Status | Evidence | -|-----------|--------|----------| -| No hardcoded Anthropic URLs | ✅ Full | `api_client.dart` now requires explicit config | -| No Anthropic-only API calls | ✅ Full | Everything routes through generic `OpenRouterClient` | -| Multi-provider support | ✅ Full | Settings support any provider via env vars | -| Vendor preference system | ✅ Full | `USE_OPENROUTER`, `USE_ANTHROPIC` flags | -| Capability preservation | ✅ Full | Same tool set works with any provider | -| Future backend readiness | ✅ Full | `kHostEndpoint` ready for custom backend | - -**Gap:** None. - ---- - -### 10. Missing/Stubbed Features — HONEST LIST ❌ - -| Feature | Type | Why | Impact | -|---------|------|-----|--------| -| Real task process spawning | Stubbed | Process management is complex | Can't execute background jobs | -| Real MCP protocol | Simulated | Requires WebSocket + full spec | Can't use external MCP servers | -| Real agent spawning | Simulated | Requires agent orchestration logic | Can't delegate to sub-agents | -| Skill execution engine | Partial | Currently template-only | Skills are text substitution, not execution | -| Full command set (25 missing) | Missing | Requires individual porting | Some commands not available | -| Daemon mode | Missing | Not critical for basic use | Background service features | -| Team/collaboration features | Missing | Requires multi-user logic | Team coordination not available | -| Browser/desktop UI | Missing | This is CLI-only | No GUI (Flutter app separate) | - -**These are clearly labeled and don't claim to be complete.** - ---- - -## Real Implementation Summary - -### What You Can Actually Do - -1. ✅ Start the REPL -2. ✅ Ask questions in natural language -3. ✅ Get model responses -4. ✅ Have the model use tools (bash, file ops, web search) -5. ✅ Maintain conversation context -6. ✅ Track costs -7. ✅ Use any OpenRouter or Anthropic model -8. ✅ Run slash commands -9. ✅ Manage permissions -10. ✅ View settings and configuration - -### What Still Requires Backend/Future Work - -1. ❌ Real background task execution -2. ❌ Real MCP server connections -3. ❌ Real agent spawning -4. ❌ Full command set (some missing) -5. ❌ Desktop UI experience - ---- - -## Parity Calculation - -**By critical path (what users actually do):** -- Can run REPL → ✅ 100% -- Can ask questions → ✅ 100% -- Model responds → ✅ 100% -- Tools execute → ✅ 100% -- Costs tracked → ✅ 100% -- Multiple vendors → ✅ 100% -- **Critical path total: 100%** ✅ - -**By feature completeness:** -- Core tools → ✅ 100% -- Permissions → ✅ 100% -- API client → ✅ 100% -- Commands → ⚠️ 70% (73/98) -- Advanced tools → ❌ 20% (mostly stubs) -- **Weighted: ~60%** - -**By code presence:** -- Code written → ✅ ~40% -- Code functional → ✅ ~55% -- Code production-ready → ✅ ~45% - -**Conservative estimate: 55-60% parity** (weighted by usability) - ---- - -## Architecture Compliance - -✅ **Anthropic umbilical severed** -- No Anthropic-only defaults -- Works with any provider -- OpenRouter as first-class option - -✅ **Capability shape preserved** -- Same tools available -- Same command structure -- Same REPL interaction model - -✅ **Local-first design** -- No local backend required -- Works with external APIs only -- CLI-first (no UI deps) - -✅ **Future SaaS-ready** -- `kHostEndpoint` ready for custom backend -- Vendor-neutral API abstraction -- Settings-driven configuration - ---- - -## What Changed Since Audit-Only Pass - -| Area | Before | After | Change | -|------|--------|-------|--------| -| Free-form prompts | Error message | Fully wired | +100% | -| Model integration | 0% | 100% | +100% | -| REPL functionality | 30% | 100% | +70% | -| Task persistence | In-memory | On-disk | +Major improvement | -| Vendor-neutral | Architecture | Implementation | +Full compliance | -| **Overall** | 33% | 55-60% | +22-27% | - ---- - -## Production Readiness Assessment - -| Aspect | Ready? | Notes | -|--------|--------|-------| -| REPL interaction | ✅ Yes | Fully functional | -| Model integration | ✅ Yes | Real API calls work | -| Core tools | ✅ Yes | File, bash, search tested | -| Permissions | ✅ Yes | All modes implemented | -| Error handling | ⚠️ Mostly | Could be more defensive | -| Performance | ✅ Yes | No obvious bottlenecks | -| Backward compat | ✅ Yes | Settings format stable | -| Vendor support | ✅ Yes | Works with multiple providers | - -**Verdict:** Ready for testing, not yet recommended for production (advanced features are stubs). - ---- - -## How to Verify This Report - -1. **Start REPL:** - ```bash - dart lib/clawd_code.dart - ``` - -2. **Set API key:** - ```bash - export OPENROUTER_API_KEY="sk-..." - ``` - -3. **Try a free-form prompt:** - ``` - clawd> Write a hello world program - ``` - -4. **Observe:** - - Model responds - - Model may call tools - - Tools execute - - Response streams in real-time - -This verifies the critical path works. - ---- - -## Conclusion - -**This is a working implementation, not a simulation.** - -The REPL is functional. The model integration is real. Tools actually execute. The app works with multiple vendors and no vendor lock-in. - -Remaining work is mostly advanced features (real MCP, real agents, task execution) that don't block basic use. - -**Status: MIGRATION COMPLETE FOR CORE FUNCTIONALITY** ✅ - -For full feature parity with old_repo, see MIGRATION_COMPLETION_REPORT.md for what remains. diff --git a/docs/legacy/QUICK_START_REPL.md b/docs/legacy/QUICK_START_REPL.md deleted file mode 100644 index 7e82ad9..0000000 --- a/docs/legacy/QUICK_START_REPL.md +++ /dev/null @@ -1,234 +0,0 @@ -# Quick Start: Using the Dart CLI REPL - -## Setup - -### 1. Install dependencies (for Flutter app parts) -```bash -flutter pub get -``` - -### 2. Set up API key -Choose one: - -**Option A: OpenRouter (vendor-neutral, recommended)** -```bash -export OPENROUTER_API_KEY="sk-or-..." -``` - -**Option B: Anthropic** -```bash -export ANTHROPIC_API_KEY="sk-ant-..." -``` - -### 3. Start the REPL -```bash -dart lib/clawd_code.dart -# OR (if you have it installed as a CLI tool) -clawd_code -``` - -You'll see: -``` -clawd_code 0.1.0 -Dart CLI migration shell. Type /help for commands. -clawd> -``` - ---- - -## Using the REPL - -### Free-form prompts (new functionality!) -Just type any question: - -``` -clawd> How do I create a web server in Go? -``` - -The model will respond and may use tools: -``` -→ Calling Bash -← Bash returned: Created main.go with basic HTTP server... - -To create a simple web server in Go, I've created a main.go file -with an HTTP server that listens on port 8080... -``` - -### Commands (slash-prefixed) -``` -clawd> /help # See all commands -clawd> /model # View/change model -clawd> /status # Show session status -clawd> /effort high # Set effort level -clawd> /clear # Clear screen -``` - -### Tool invocations (syntax: `toolname: args`) -``` -clawd> bash: ls -la -clawd> read: /path/to/file -clawd> grep: pattern lib/src -clawd> glob: **/*.dart -``` - -### Conversation history -Your conversation is maintained in memory during the session: -``` -clawd> Write a function to reverse a string - - -clawd> Can you add error handling? - -``` - ---- - -## What Works - -✅ **Free-form prompts** → Model processes them -✅ **Model tool calls** → Bash, file ops, search, etc. execute -✅ **Streaming** → See responses as they're generated -✅ **Conversation history** → Model remembers context -✅ **Cost tracking** → See how much you've spent -✅ **Multiple vendors** → Works with OpenRouter or Anthropic - ---- - -## What Doesn't Work Yet - -❌ **Real background tasks** → `/tasks` stores metadata only, no execution -❌ **Real MCP servers** → `/mcp` is simulated -❌ **Real agents** → `/agents` is simulated -❌ **Some commands** → 25+ commands not yet ported - ---- - -## Environment Variables - -Control behavior with these variables: - -```bash -# API selection -export USE_OPENROUTER=true # Prefer OpenRouter -export USE_ANTHROPIC=true # Prefer Anthropic - -# API keys -export OPENROUTER_API_KEY="..." -export ANTHROPIC_API_KEY="..." - -# Model override (if not using /model command) -export CLAUDE_CODE_MODEL="gpt-4" # OpenRouter model - -# Debug -export CLAWD_DEBUG=true # More verbose output -``` - ---- - -## Troubleshooting - -### "No API key configured" -```bash -export OPENROUTER_API_KEY="your-key-here" -# OR -export ANTHROPIC_API_KEY="your-key-here" -``` - -### "Base URL not configured" -This is the new vendor-neutral requirement. Set one of: -```bash -export OPENROUTER_BASE_URL="https://openrouter.ai/api/v1" -export ANTHROPIC_BASE_URL="https://api.anthropic.com/v1" -export CLAUDE_CODE_BASE_URL="https://your-backend.com/api/v1" -``` - -### "Unknown model" -Set a valid model name: -```bash -/model claude-opus-4 -# OR -/model openrouter/auto -``` - -### Model is slow -Check your API key is valid and you have quota available. - -### Task not persisting -Tasks are saved to `~/.clawd_code/tasks/` as JSON files. Check that directory exists and is writable. - ---- - -## Examples - -### Example 1: Code generation -``` -clawd> Write a Dart async function that fetches data from an API - - -``` - -### Example 2: File editing -``` -clawd> Read lib/src/app.dart and explain the main entry point - - -``` - -### Example 3: Debugging -``` -clawd> Search lib/ for all uses of BashTool - - -``` - -### Example 4: Web research -``` -clawd> Search for best practices for Dart CLI development - - -``` - ---- - -## Cost Tracking - -After each model call, you'll see: -``` -$0.05 for this prompt (1,234 input + 456 output tokens) -Session total: $0.32 -``` - -Costs saved to `~/.claude/last_session_cost.json` on exit. - ---- - -## Next Steps - -- [ ] Try `/help` to see all commands -- [ ] Try a free-form prompt about your codebase -- [ ] Use `/tasks create` to track work -- [ ] Use `/config` to see and change settings -- [ ] Use `/theme` to change appearance - ---- - -## Architecture Note - -This REPL implementation: -- ✅ Is vendor-neutral (works with OpenRouter, Anthropic, or custom backend) -- ✅ Works without any backend (model API is external, not local) -- ✅ Maintains conversation history in memory -- ✅ Integrates with full tool system (permissions, telemetry, etc.) -- ❌ Does not require Flutter (pure Dart CLI) - -For the full Dart CLI experience without Flutter deps, run: -```bash -dart lib/clawd_code.dart -``` - -For the Flutter app with UI, run: -```bash -flutter run -``` - -Both use the same underlying model integration. diff --git a/docs/legacy/README_MIGRATION.md b/docs/legacy/README_MIGRATION.md deleted file mode 100644 index c73f941..0000000 --- a/docs/legacy/README_MIGRATION.md +++ /dev/null @@ -1,247 +0,0 @@ -# Dart CLI Migration: Complete Status - -## TL;DR - -✅ **Migration is functionally complete for core features** - -- Interactive REPL works -- Model integration works -- Tools execute -- Costs tracked -- Vendor-neutral design verified - -**Parity: 55-60%** (weighted by critical path) - -See `PARITY_STATUS.md` for detailed breakdown. - ---- - -## What Was Done This Implementation Pass - -### Real Work (Not Just Audit) - -1. **Free-form prompt handler** — Wired user input directly to model via ToolLoopService -2. **REPL integration** — Connected CLI prompt loop to model execution -3. **Task persistence** — Changed from in-memory to disk-backed JSON storage -4. **Cost tracking integration** — Model calls now properly track costs -5. **Vendor-neutral defaults** — Removed Anthropic hardcoding, supports multiple providers - -### Lines of Code Impact - -``` -lib/src/chat/repl_handler.dart NEW 106 lines (free-form handler) -lib/src/app.dart MODIFIED +30 lines (REPL integration) -lib/src/tools/task_tool.dart MODIFIED +90 lines (persistence) -lib/src/services/api_client.dart MODIFIED +10 lines (vendor-neutral) -``` - -All changes are **real implementation**, not scaffolding. - ---- - -## How to Use It - -### Start the REPL -```bash -dart lib/clawd_code.dart -``` - -### Set API key (choose one) -```bash -export OPENROUTER_API_KEY="sk-or-..." # Preferred (vendor-neutral) -# OR -export ANTHROPIC_API_KEY="sk-ant-..." # Alternative -``` - -### Ask questions -``` -clawd> How do I parse JSON in Dart? -``` - -The model responds with code, may call tools (read files, run bash, search), and returns the answer. - -Full guide: `QUICK_START_REPL.md` - ---- - -## Key Achievements - -| Goal | Status | Evidence | -|------|--------|----------| -| REPL works | ✅ | Full interactive loop, accepts free-form input | -| Model calls work | ✅ | OpenRouter/Anthropic integration complete | -| Tools execute | ✅ | Bash, file ops, web search all functional | -| Vendor-neutral | ✅ | No Anthropic defaults, supports multiple providers | -| Costs tracked | ✅ | Per-call and session-level tracking | -| Task persistence | ✅ | Tasks saved to disk in `~/.clawd_code/tasks/` | -| Conversation history | ✅ | Maintained during session for multi-turn interaction | - ---- - -## What Still Needs Work - -| Feature | Type | Effort | Impact | -|---------|------|--------|--------| -| Real task spawning | Stubbed | High | Can't run background processes | -| Real MCP protocol | Simulated | Very High | Can't use external MCP servers | -| Real agent spawning | Simulated | High | Can't delegate to sub-agents | -| Remaining 25 commands | Missing | Medium | Some commands not available | -| Skill execution engine | Partial | Medium | Skills are template-only | - -These are **clearly marked as incomplete** and don't claim to be done. - ---- - -## Documentation - -### For Implementation Details -- `MIGRATION_COMPLETION_REPORT.md` — What was built, how it works, end-to-end flow -- `PARITY_STATUS.md` — Detailed subsystem-by-subsystem parity breakdown - -### For Testing -- `QUICK_START_REPL.md` — How to use the REPL, examples, troubleshooting - -### For Architecture -- `FINAL_PARITY_AUDIT.md` — Original audit methodology and findings -- `IMPLEMENTATION_SUMMARY.md` — Quick reference of status - -### For Code -- Core changes are in `lib/src/chat/repl_handler.dart` (new) and `lib/src/app.dart` (integration) -- All other changes are incremental improvements to existing systems - ---- - -## Architecture Verification - -✅ **Anthropic umbilical severed** -- No Anthropic-only code paths -- Supports OpenRouter, Anthropic, and custom backends -- Environment variables control provider selection - -✅ **Capability shape preserved** -- Same REPL interaction -- Same tool set -- Same command structure -- Same cost tracking - -✅ **Works without backend** -- Model API is external (OpenRouter or Anthropic) -- No local server required -- Can use today with just an API key - -✅ **Ready for future SaaS backend** -- `kHostEndpoint` already in place -- Settings-driven configuration -- Vendor-neutral abstractions ready - ---- - -## Code Quality - -**Good:** -- Clear separation of concerns (ReplHandler, ToolLoopService, API client) -- Proper error handling and user-friendly messages -- Vendor-neutral abstractions working correctly -- Cost tracking integrated properly - -**Could improve:** -- Remove debug print statements in ToolLoopService (lines 154, 164, 172) -- Add more comprehensive error messages for network failures -- Document task persistence format - -**Known limitations:** -- Task tool doesn't spawn actual processes (noted in code) -- MCP tool is completely mocked (labeled clearly) -- Some commands not yet ported (list available) - ---- - -## Migration Path Forward - -### Immediate (if needed) -1. Remove debug print statements from ToolLoopService -2. Test with real OpenRouter and Anthropic keys -3. Verify all core tools work end-to-end -4. Add integration tests for REPL + model + tools flow - -### Medium Term (5-10 hours each) -1. Implement real task process spawning -2. Port remaining 25 commands -3. Implement skill execution engine -4. Add session persistence (history saved between restarts) - -### Long Term (20+ hours each) -1. Implement real MCP protocol client -2. Implement real agent spawning and coordination -3. Build desktop UI (separate from CLI) -4. Add team collaboration features - ---- - -## File Organization - -``` -lib/src/ -├── chat/ -│ ├── repl_handler.dart ← NEW (free-form prompts) -│ ├── tool_loop_service.dart ← Real (model + tool integration) -│ └── ... -├── api/ -│ ├── openrouter_client.dart ← Real (API calls) -│ ├── api_types.dart ← Real (message types) -│ └── ... -├── tools/ -│ ├── bash_tool.dart ← Real (subprocess) -│ ├── task_tool.dart ← Improved (persistence) -│ ├── web_search_tool.dart ← Real (OpenRouter) -│ ├── web_fetch_tool.dart ← Real (HTTP + parsing) -│ └── ... -├── services/ -│ ├── cost_tracker.dart ← Real (usage tracking) -│ ├── api_client.dart ← Improved (vendor-neutral) -│ └── ... -├── app.dart ← Improved (REPL integration) -└── ... -``` - ---- - -## Success Criteria Met - -- ✅ Free-form prompts execute against model -- ✅ Model can invoke tools -- ✅ Tools execute and return results -- ✅ Responses stream in real-time -- ✅ Costs are tracked properly -- ✅ No Anthropic vendor lock-in -- ✅ Works with multiple providers -- ✅ Architecture ready for future backend -- ✅ Conversation history maintained -- ✅ All changes are real implementation, not stubs - ---- - -## Honesty Pledge - -This report and implementation: -- ✅ Does not overclaim completed features -- ✅ Clearly marks stubbed/incomplete work -- ✅ Provides exact parity percentages with methodology -- ✅ Lists remaining gaps explicitly -- ✅ Shows real working code, not demos -- ✅ Maintains architectural principles - ---- - -## Quick Links - -- **Getting started:** `QUICK_START_REPL.md` -- **Detailed parity:** `PARITY_STATUS.md` -- **Implementation details:** `MIGRATION_COMPLETION_REPORT.md` -- **Architecture:** `FINAL_PARITY_AUDIT.md` - ---- - -**Status: FUNCTIONAL IMPLEMENTATION COMPLETE** ✅ - -The core interactive flow works. The app can be used for real work with any OpenRouter or Anthropic model. Advanced features remain to be implemented but don't block basic functionality. diff --git a/docs/legacy/SCROLLING_FIX_SUMMARY.md b/docs/legacy/SCROLLING_FIX_SUMMARY.md deleted file mode 100644 index e21d644..0000000 --- a/docs/legacy/SCROLLING_FIX_SUMMARY.md +++ /dev/null @@ -1,59 +0,0 @@ -# Chat View Scrolling Fix Summary - -## Problem Analysis -The chat view had inconsistent scroll thumb behavior where it would "jump around" during use. This was caused by: - -1. **Aggressive auto-scrolling**: The `_scrollToBottom()` function was called on every build when messages were present -2. **Interference with user scrolling**: During message streaming, the chat provider calls `notifyListeners()` frequently (on each text delta), triggering rebuilds and auto-scrolling -3. **No user scroll detection**: The system couldn't distinguish between user-initiated scrolling and auto-scrolling - -## Solution Implemented - -### 1. Smart Auto-Scrolling Logic -- Only auto-scrolls when new messages arrive AND user is near the bottom (within 150px) -- Uses `_isNearBottom()` to check scroll position -- Tracks actual message content changes, not just rebuilds - -### 2. User Scroll Detection -- Uses `ScrollController` listener to detect when user is scrolling -- Implements 150ms debouncing to detect when scrolling stops -- Sets `_isUserScrolling` flag to prevent auto-scrolling while user is interacting - -### 3. Jump-to-Bottom Button -- When user scrolls away from bottom (>200px) and new messages arrive, shows a "New messages" button -- Button appears in bottom-right corner with subtle animation -- Clicking it smoothly scrolls to bottom and hides the button -- Button only shows when there are actually new messages while user is scrolled away - -### 4. Message Change Tracking -- Tracks previous message contents to detect actual changes (not just re-renders) -- Prevents unnecessary auto-scrolling on provider updates that don't change message content - -## Technical Details - -### Key Variables -- `_isUserScrolling`: Tracks if user is actively scrolling -- `_showJumpToBottom`: Whether to show the jump-to-bottom button -- `_hasNewMessagesWhileScrolledAway`: Whether new messages arrived while user was scrolled away -- `_previousMessageContents`: List of previous message contents for change detection - -### Scroll Thresholds -- **Near bottom**: Within 150px of bottom (triggers auto-scroll) -- **Far from bottom**: More than 200px from bottom (shows jump button) -- **Debounce timeout**: 150ms (detects scroll stop) - -## Benefits -1. **Smooth scrolling**: No more jumpy scroll thumb during streaming -2. **User control**: Users can scroll up to read previous messages without being forced back to bottom -3. **Clear UX**: Jump-to-bottom button provides clear indication of new messages -4. **Performance**: Reduces unnecessary scroll animations - -## Testing -To test the fix: -1. Send multiple messages to create a scrollable chat -2. Scroll up to read previous messages during streaming -3. Observe that auto-scroll doesn't interfere -4. See the jump-to-bottom button appear when new messages arrive -5. Click the button to smoothly return to bottom - -The fix maintains the original behavior for users who are at/near the bottom while preventing the disruptive scrolling behavior for users actively reading previous messages. \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/lib/main.dart b/lib/main.dart index c7fdb6f..e08b5ab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,6 +35,7 @@ void main() async { ChangeNotifierProvider( create: (context) => ChatProvider( context.read(), + context.read(), ), ), ChangeNotifierProvider( diff --git a/lib/src/api/openrouter_client.dart b/lib/src/api/openrouter_client.dart index 3188691..cc40e1e 100644 --- a/lib/src/api/openrouter_client.dart +++ b/lib/src/api/openrouter_client.dart @@ -71,6 +71,7 @@ class OpenRouterClient { double? temperature, List>? tools, String? toolChoice, + String? reasoning, // "low" | "medium" | "high" — maps to OpenRouter reasoning.effort }) async { final requestBody = { "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"] = {"effort": effort}; + } + final response = await _withRetry( () => _makeRequest( method: "POST", @@ -118,6 +126,7 @@ class OpenRouterClient { double? temperature, List>? tools, String? toolChoice, + String? reasoning, // "low" | "medium" | "high" — maps to OpenRouter reasoning.effort void Function(String delta)? onTextDelta, }) async { final requestBody = { @@ -148,6 +157,11 @@ class OpenRouterClient { } } + if (reasoning != null) { + final effort = reasoning == 'max' ? 'xhigh' : reasoning; + requestBody["reasoning"] = {"effort": effort}; + } + final url = Uri.parse("$_baseUrl/chat/completions"); final headers = _buildHeaders(); diff --git a/lib/src/app.dart b/lib/src/app.dart index 7bcef9e..0cec49f 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -4,8 +4,79 @@ import 'dart:io'; import 'build_info.dart'; import 'chat/repl_handler.dart'; import 'command.dart'; -import 'daemon/daemon_manager.dart'; -import 'daemon/daemon_types.dart'; +import 'commands/add_dir.dart' as cmdAddDir; +import 'commands/advisor.dart' as cmdAdvisor; +import 'commands/agents.dart' as cmdAgents; +import 'commands/attach.dart' as cmdAttach; +import 'commands/branch.dart' as cmdBranch; +import 'commands/brief.dart' as cmdBrief; +import 'commands/btw.dart' as cmdBtw; +import 'commands/bughunter.dart' as cmdBughunter; +import 'commands/chrome.dart' as cmdChrome; +import 'commands/clear.dart' as cmdClear; +import 'commands/color.dart' as cmdColor; +import 'commands/commit.dart' as cmdCommit; +import 'commands/commit_push_pr.dart' as cmdCommitPushPr; +import 'commands/compact.dart' as cmdCompact; +import 'commands/config.dart' as cmdConfig; +import 'commands/context.dart' as cmdContext; +import 'commands/copy.dart' as cmdCopy; +import 'commands/cost.dart' as cmdCost; +import 'commands/desktop.dart' as cmdDesktop; +import 'commands/diff.dart' as cmdDiff; +import 'commands/doctor.dart' as cmdDoctor; +import 'commands/effort.dart' as cmdEffort; +import 'commands/env.dart' as cmdEnv; +import 'commands/exit.dart' as cmdExit; +import 'commands/export.dart' as cmdExport; +import 'commands/fast.dart' as cmdFast; +import 'commands/feedback.dart' as cmdFeedback; +import 'commands/files.dart' as cmdFiles; +import 'commands/help.dart' as cmdHelp; +import 'commands/hooks.dart' as cmdHooks; +import 'commands/ide.dart' as cmdIde; +import 'commands/init.dart' as cmdInit; +import 'commands/init_verifiers.dart' as cmdInitVerifiers; +import 'commands/install_github_app.dart' as cmdInstallGithubApp; +import 'commands/keybindings.dart' as cmdKeybindings; +import 'commands/kill.dart' as cmdKill; +import 'commands/lint.dart' as cmdLint; +import 'commands/login.dart' as cmdLogin; +import 'commands/logout.dart' as cmdLogout; +import 'commands/logs.dart' as cmdLogs; +import 'commands/mcp.dart' as cmdMcp; +import 'commands/memory.dart' as cmdMemory; +import 'commands/mobile.dart' as cmdMobile; +import 'commands/model.dart' as cmdModel; +import 'commands/output_style.dart' as cmdOutputStyle; +import 'commands/permissions.dart' as cmdPermissions; +import 'commands/plan.dart' as cmdPlan; +import 'commands/plugin.dart' as cmdPlugin; +import 'commands/pr_comments.dart' as cmdPrComments; +import 'commands/privacy_settings.dart' as cmdPrivacySettings; +import 'commands/ps.dart' as cmdPs; +import 'commands/release_notes.dart' as cmdReleaseNotes; +import 'commands/rename.dart' as cmdRename; +import 'commands/resume.dart' as cmdResume; +import 'commands/review.dart' as cmdReview; +import 'commands/rewind.dart' as cmdRewind; +import 'commands/security_review.dart' as cmdSecurityReview; +import 'commands/session.dart' as cmdSession; +import 'commands/skills.dart' as cmdSkills; +import 'commands/stats.dart' as cmdStats; +import 'commands/status.dart' as cmdStatus; +import 'commands/statusline.dart' as cmdStatusline; +import 'commands/stickers.dart' as cmdStickers; +import 'commands/tag.dart' as cmdTag; +import 'commands/tasks.dart' as cmdTasks; +import 'commands/terminal_setup.dart' as cmdTerminalSetup; +import 'commands/theme.dart' as cmdTheme; +import 'commands/tools.dart' as cmdTools; +import 'commands/upgrade.dart' as cmdUpgrade; +import 'commands/usage.dart' as cmdUsage; +import 'commands/version.dart' as cmdVersion; +import 'commands/vim.dart' as cmdVim; +import 'commands/voice.dart' as cmdVoice; import 'hooks/hook_loader.dart'; import 'hooks/hook_runner.dart'; import 'hooks/hook_types.dart'; @@ -15,49 +86,14 @@ import 'legacy_inventory.dart'; import 'local_state.dart'; import 'migration_assessment.dart'; import 'runtime_state.dart'; -import 'services/cost_tracker.dart' as costTracker; import 'services/analytics_service.dart'; +import 'services/cost_tracker.dart' as costTracker; import 'services/usage_tracker.dart'; -import 'session/conversation_history.dart'; -import 'session/session_store.dart'; import 'session/session_types.dart'; import 'tools/tool_registry.dart'; +import 'utils/path_utils.dart'; import 'utils/uuid_utils.dart'; -const _jsonEncoder = JsonEncoder.withIndent(' '); -const _colorResetAliases = ['default', 'reset', 'none', 'gray', 'grey']; -const _commonHelpArgs = ['help', '-h', '--help']; -const _commonInfoArgs = ['current', 'info', 'show', 'status']; -const _defaultStatuslinePrompt = - 'Configure my statusLine from my shell PS1 configuration'; -const _initHeader = - '# CLAUDE.md\n\n' - 'This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n'; -const _effortHelpText = - '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'; -const _modelAliases = [ - 'best', - 'haiku', - 'opus', - 'opus[1m]', - 'opusplan', - 'sonnet', - 'sonnet[1m]', -]; -const _max20xTier = 'default_claude_max_20x'; -const _largeClaudeMdWarningChars = 40000; - -// global in-memory history for the current run -final _history = ConversationHistory(); - -String _makeSessionId() => generateUuid(); - Future runClawdCode(List args) async { final settingsStore = await SettingsStore.load(); final runtimeStateStore = await RuntimeStateStore.load(); @@ -71,7 +107,6 @@ Future runClawdCode(List args) async { ); }); - // Load hooks configuration final loadedHooks = await HookLoader.loadHooks(); final cli = _ClawdCli( @@ -92,486 +127,79 @@ CommandCatalog _buildCatalog() { return CommandCatalog( legacyCommands: legacyCommandInventory, portedCommands: [ - CommandSpec( - name: 'help', - description: 'Show help and available commands', - legacySourcePath: 'old_repo/commands/help/index.ts', - handler: _runHelp, - ), - CommandSpec( - name: 'status', - description: - 'Show Claude Code status including migration, model, account, and tool settings', - legacySourcePath: 'old_repo/commands/status/index.ts', - handler: _runStatus, - ), - CommandSpec( - name: 'version', - description: - 'Print the version this session is running (not what autoupdate downloaded)', - legacySourcePath: 'old_repo/commands/version.ts', - kind: CommandKind.local, - handler: _runVersion, - ), - CommandSpec( - name: 'clear', - description: 'Clear the terminal screen', - aliases: ['reset', 'new'], - legacySourcePath: 'old_repo/commands/clear/index.ts', - handler: _runClear, - ), - CommandSpec( - name: 'exit', - description: 'Exit the REPL', - aliases: ['quit'], - legacySourcePath: 'old_repo/commands/exit/index.ts', - handler: _runExit, - ), - CommandSpec( - name: 'config', - description: 'Show the current config file and active settings', - aliases: ['settings'], - legacySourcePath: 'old_repo/commands/config/index.ts', - handler: _runConfig, - ), - CommandSpec( - name: 'vim', - description: 'Toggle between Vim and Normal editing modes', - legacySourcePath: 'old_repo/commands/vim/index.ts', - kind: CommandKind.local, - handler: _runVim, - ), - CommandSpec( - name: 'theme', - description: 'Change the theme', - legacySourcePath: 'old_repo/commands/theme/index.ts', - handler: _runTheme, - ), - CommandSpec( - name: 'effort', - description: 'Set effort level for model usage', - legacySourcePath: 'old_repo/commands/effort/index.ts', - handler: _runEffort, - ), - CommandSpec( - name: 'plan', - description: 'Enable plan mode or view the current session plan', - legacySourcePath: 'old_repo/commands/plan/index.ts', - handler: _runPlan, - ), - CommandSpec( - name: 'color', - description: 'Set the prompt bar color for this session', - legacySourcePath: 'old_repo/commands/color/index.ts', - handler: _runColor, - ), - CommandSpec( - name: 'output-style', - description: 'Deprecated: use /config to change output style', - legacySourcePath: 'old_repo/commands/output-style/index.ts', - handler: _runOutputStyle, - ), - CommandSpec( - name: 'fast', - description: 'Toggle fast mode (Opus 4.6 only)', - legacySourcePath: 'old_repo/commands/fast/index.ts', - handler: _runFast, - ), - CommandSpec( - name: 'cost', - description: 'Show the total cost and duration of the current session', - legacySourcePath: 'old_repo/commands/cost/index.ts', - kind: CommandKind.local, - handler: _runCost, - ), - CommandSpec( - name: 'doctor', - description: - 'Diagnose and verify your Claude Code installation and settings', - legacySourcePath: 'old_repo/commands/doctor/index.ts', - handler: _runDoctor, - ), - CommandSpec( - name: 'init', - description: - 'Initialize a new CLAUDE.md file with codebase documentation', - legacySourcePath: 'old_repo/commands/init.ts', - kind: CommandKind.prompt, - handler: _runInit, - ), - CommandSpec( - name: 'login', - description: 'Configure your OpenRouter API key (set in settings)', - legacySourcePath: 'old_repo/commands/login/index.ts', - handler: _runLogin, - ), - CommandSpec( - name: 'logout', - description: 'Remove your OpenRouter API key (clear in settings)', - legacySourcePath: 'old_repo/commands/logout/index.ts', - handler: _runLogout, - ), - CommandSpec( - name: 'model', - description: 'Set the AI model for Claude Code', - legacySourcePath: 'old_repo/commands/model/index.ts', - handler: _runModel, - ), - CommandSpec( - name: 'permissions', - description: 'Manage allow & deny tool permission rules', - aliases: ['allowed-tools'], - legacySourcePath: 'old_repo/commands/permissions/index.ts', - handler: _runPermissions, - ), - CommandSpec( - name: 'stats', - description: 'Show CLI usage statistics and activity', - legacySourcePath: 'old_repo/commands/stats/index.ts', - handler: _runStats, - ), - CommandSpec( - name: 'statusline', - description: "Set up Claude Code's status line UI", - legacySourcePath: 'old_repo/commands/statusline.tsx', - kind: CommandKind.prompt, - handler: _runStatusline, - ), - CommandSpec( - name: 'upgrade', - description: 'Upgrade to Max for higher rate limits and more Opus', - legacySourcePath: 'old_repo/commands/upgrade/index.ts', - handler: _runUpgrade, - ), - CommandSpec( - name: 'usage', - description: 'Show plan usage limits', - legacySourcePath: 'old_repo/commands/usage/index.ts', - handler: _runUsage, - ), - CommandSpec( - name: 'tag', - description: 'Toggle a searchable tag on the current session', - legacySourcePath: 'old_repo/commands/tag/index.ts', - kind: CommandKind.local, - handler: _runTag, - ), - CommandSpec( - name: 'env', - description: 'Show relevant environment variables for this session', - legacySourcePath: 'old_repo/commands/env/index.js', - kind: CommandKind.local, - handler: _runEnv, - ), - CommandSpec( - name: 'files', - description: 'List all files currently in context', - legacySourcePath: 'old_repo/commands/files/index.ts', - kind: CommandKind.local, - handler: _runFiles, - ), - CommandSpec( - name: 'branch', - description: 'Create a branch of the current conversation at this point', - aliases: ['fork'], - legacySourcePath: 'old_repo/commands/branch/index.ts', - handler: _runBranch, - ), - CommandSpec( - name: 'export', - description: 'Export the current conversation to a file or clipboard', - legacySourcePath: 'old_repo/commands/export/index.ts', - handler: _runExport, - ), - CommandSpec( - name: 'memory', - description: 'Edit Claude memory files', - legacySourcePath: 'old_repo/commands/memory/index.ts', - handler: _runMemory, - ), - CommandSpec( - name: 'diff', - description: 'Show a diff of changes made in this conversation', - legacySourcePath: 'old_repo/commands/diff/index.ts', - handler: _runDiff, - ), - CommandSpec( - name: 'rename', - description: 'Rename the current conversation', - legacySourcePath: 'old_repo/commands/rename/index.ts', - handler: _runRename, - ), - CommandSpec( - name: 'copy', - description: "Copy Claude's last response to clipboard", - legacySourcePath: 'old_repo/commands/copy/index.ts', - handler: _runCopy, - ), - CommandSpec( - name: 'keybindings', - description: 'Open the keybindings config file in your editor', - legacySourcePath: 'old_repo/commands/keybindings/index.ts', - handler: _runKeybindings, - ), - CommandSpec( - name: 'add-dir', - description: 'Add a new working directory to the session', - legacySourcePath: 'old_repo/commands/add-dir/index.ts', - handler: _runAddDir, - ), - CommandSpec( - name: 'brief', - description: 'Toggle brief-only mode', - legacySourcePath: 'old_repo/commands/brief.ts', - kind: CommandKind.local, - handler: _runBrief, - ), - CommandSpec( - name: 'context', - description: 'Show current context window usage', - legacySourcePath: 'old_repo/commands/context/index.ts', - kind: CommandKind.local, - handler: _runContext, - ), - CommandSpec( - name: 'compact', - description: 'Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]', - legacySourcePath: 'old_repo/commands/compact/index.ts', - kind: CommandKind.local, - handler: _runCompact, - ), - CommandSpec( - name: 'resume', - description: 'Resume a previous conversation', - aliases: ['continue'], - legacySourcePath: 'old_repo/commands/resume/index.ts', - handler: _runResume, - ), - CommandSpec( - name: 'review', - description: 'Review a pull request', - legacySourcePath: 'old_repo/commands/review.ts', - kind: CommandKind.prompt, - handler: _runReview, - ), - CommandSpec( - name: 'hooks', - description: 'View hook configurations for tool events', - legacySourcePath: 'old_repo/commands/hooks/index.ts', - handler: _runHooks, - ), - CommandSpec( - name: 'privacy-settings', - description: 'View and update your privacy settings', - legacySourcePath: 'old_repo/commands/privacy-settings/index.ts', - handler: _runPrivacySettings, - ), - CommandSpec( - name: 'release-notes', - description: 'View release notes', - legacySourcePath: 'old_repo/commands/release-notes/index.ts', - kind: CommandKind.local, - handler: _runReleaseNotes, - ), - CommandSpec( - name: 'feedback', - description: 'Submit feedback about Claude Code', - aliases: ['bug'], - legacySourcePath: 'old_repo/commands/feedback/index.ts', - handler: _runFeedback, - ), - CommandSpec( - name: 'pr-comments', - description: 'Get comments from a GitHub pull request', - legacySourcePath: 'old_repo/commands/pr_comments/index.ts', - kind: CommandKind.prompt, - handler: _runPrComments, - ), - CommandSpec( - name: 'commit', - description: 'Create a git commit', - legacySourcePath: 'old_repo/commands/commit.ts', - kind: CommandKind.prompt, - handler: _runCommit, - ), - CommandSpec( - name: 'lint', - description: 'Run linting on the current project', - legacySourcePath: 'old_repo/commands/lint', - kind: CommandKind.local, - handler: _runLint, - ), - CommandSpec( - name: 'mcp', - description: 'Manage MCP servers', - legacySourcePath: 'old_repo/commands/mcp/index.ts', - handler: _runMcp, - ), - CommandSpec( - name: 'advisor', - description: 'Configure the advisor model', - legacySourcePath: 'old_repo/commands/advisor.ts', - kind: CommandKind.local, - handler: _runAdvisor, - ), - CommandSpec( - name: 'bughunter', - description: 'Toggle bug hunter mode', - legacySourcePath: 'old_repo/commands/bughunter/index.js', - kind: CommandKind.local, - handler: _runBughunter, - ), - CommandSpec( - name: 'terminal-setup', - description: 'Install terminal key bindings for newlines', - legacySourcePath: 'old_repo/commands/terminalSetup/index.ts', - handler: _runTerminalSetup, - ), - CommandSpec( - name: 'install-github-app', - description: 'Set up Claude GitHub Actions for a repository', - legacySourcePath: 'old_repo/commands/install-github-app/index.ts', - handler: _runInstallGithubApp, - ), - CommandSpec( - name: 'desktop', - description: 'Continue the current session in Claude Desktop', - aliases: ['app'], - legacySourcePath: 'old_repo/commands/desktop/index.ts', - handler: _runDesktop, - ), - CommandSpec( - name: 'mobile', - description: 'Show QR code to download the Claude mobile app', - aliases: ['ios', 'android'], - legacySourcePath: 'old_repo/commands/mobile/index.ts', - handler: _runMobile, - ), - CommandSpec( - name: 'chrome', - description: 'Claude in Chrome (Beta) settings', - legacySourcePath: 'old_repo/commands/chrome/index.ts', - handler: _runChrome, - ), - CommandSpec( - name: 'ide', - description: 'Manage IDE integrations and show status', - legacySourcePath: 'old_repo/commands/ide/index.ts', - handler: _runIde, - ), - CommandSpec( - name: 'agents', - description: 'Manage agent configurations', - legacySourcePath: 'old_repo/commands/agents/index.ts', - handler: _runAgents, - ), - CommandSpec( - name: 'tasks', - description: 'List and manage background tasks', - aliases: ['bashes'], - legacySourcePath: 'old_repo/commands/tasks/index.ts', - handler: _runTasks, - ), - CommandSpec( - name: 'stickers', - description: 'Order Claude Code stickers', - legacySourcePath: 'old_repo/commands/stickers/index.ts', - kind: CommandKind.local, - handler: _runStickers, - ), - CommandSpec( - name: 'voice', - description: 'Toggle voice mode', - legacySourcePath: 'old_repo/commands/voice/index.ts', - kind: CommandKind.local, - handler: _runVoice, - ), - CommandSpec( - name: 'btw', - description: 'Ask a quick side question without interrupting the main conversation', - legacySourcePath: 'old_repo/commands/btw/index.ts', - handler: _runBtw, - ), - CommandSpec( - name: 'rewind', - description: 'Restore the code and/or conversation to a previous point', - aliases: ['checkpoint'], - legacySourcePath: 'old_repo/commands/rewind/index.ts', - kind: CommandKind.local, - handler: _runRewind, - ), - CommandSpec( - name: 'plugin', - description: 'Manage Claude Code plugins', - aliases: ['plugins', 'marketplace'], - legacySourcePath: 'old_repo/commands/plugin/index.tsx', - handler: _runPlugin, - ), - CommandSpec( - name: 'session', - description: 'Show remote session URL and QR code', - aliases: ['remote'], - legacySourcePath: 'old_repo/commands/session/index.ts', - handler: _runSession, - ), - CommandSpec( - name: 'skills', - description: 'List available skills', - legacySourcePath: 'old_repo/commands/skills/index.ts', - handler: _runSkills, - ), - CommandSpec( - name: 'ps', - description: 'List background Claude sessions', - legacySourcePath: 'old_repo/commands/session/index.ts', - handler: _runPs, - ), - CommandSpec( - name: 'logs', - description: 'Show logs for a background session', - legacySourcePath: 'old_repo/commands/session/index.ts', - handler: _runLogs, - ), - CommandSpec( - name: 'attach', - description: 'Attach (tail logs) to a background session', - legacySourcePath: 'old_repo/commands/session/index.ts', - handler: _runAttach, - ), - CommandSpec( - name: 'kill', - description: 'Kill a background session', - legacySourcePath: 'old_repo/commands/session/index.ts', - handler: _runKill, - ), - CommandSpec( - name: 'commit-push-pr', - description: 'Commit, push, and open a PR', - legacySourcePath: 'old_repo/commands/commit-push-pr.ts', - kind: CommandKind.prompt, - handler: _runCommitPushPr, - ), - CommandSpec( - name: 'init-verifiers', - description: 'Create verifier skill(s) for automated verification of code changes', - legacySourcePath: 'old_repo/commands/init-verifiers.ts', - kind: CommandKind.prompt, - handler: _runInitVerifiers, - ), - CommandSpec( - name: 'security-review', - description: 'Complete a security review of the pending changes on the current branch', - legacySourcePath: 'old_repo/commands/security-review.ts', - kind: CommandKind.prompt, - handler: _runSecurityReview, - ), - CommandSpec( - name: 'tools', - description: 'List all registered tools with their descriptions', - legacySourcePath: 'old_repo/commands/tools.ts', - kind: CommandKind.local, - handler: _runTools, - ), + CommandSpec(name: 'help', description: 'Show help and available commands', legacySourcePath: 'old_repo/commands/help/index.ts', handler: cmdHelp.run), + CommandSpec(name: 'status', description: 'Show Claude Code status including migration, model, account, and tool settings', legacySourcePath: 'old_repo/commands/status/index.ts', handler: cmdStatus.run), + CommandSpec(name: 'version', description: 'Print the version this session is running (not what autoupdate downloaded)', legacySourcePath: 'old_repo/commands/version.ts', kind: CommandKind.local, handler: cmdVersion.run), + CommandSpec(name: 'clear', description: 'Clear the terminal screen', aliases: ['reset', 'new'], legacySourcePath: 'old_repo/commands/clear/index.ts', handler: cmdClear.run), + CommandSpec(name: 'exit', description: 'Exit the REPL', aliases: ['quit'], legacySourcePath: 'old_repo/commands/exit/index.ts', handler: cmdExit.run), + CommandSpec(name: 'config', description: 'Show the current config file and active settings', aliases: ['settings'], legacySourcePath: 'old_repo/commands/config/index.ts', handler: cmdConfig.run), + CommandSpec(name: 'vim', description: 'Toggle between Vim and Normal editing modes', legacySourcePath: 'old_repo/commands/vim/index.ts', kind: CommandKind.local, handler: cmdVim.run), + CommandSpec(name: 'theme', description: 'Change the theme', legacySourcePath: 'old_repo/commands/theme/index.ts', handler: cmdTheme.run), + CommandSpec(name: 'effort', description: 'Set effort level for model usage', legacySourcePath: 'old_repo/commands/effort/index.ts', handler: cmdEffort.run), + CommandSpec(name: 'plan', description: 'Enable plan mode or view the current session plan', legacySourcePath: 'old_repo/commands/plan/index.ts', handler: cmdPlan.run), + CommandSpec(name: 'color', description: 'Set the prompt bar color for this session', legacySourcePath: 'old_repo/commands/color/index.ts', handler: cmdColor.run), + CommandSpec(name: 'output-style', description: 'Deprecated: use /config to change output style', legacySourcePath: 'old_repo/commands/output-style/index.ts', handler: cmdOutputStyle.run), + CommandSpec(name: 'fast', description: 'Toggle fast mode (Opus 4.6 only)', legacySourcePath: 'old_repo/commands/fast/index.ts', handler: cmdFast.run), + CommandSpec(name: 'cost', description: 'Show the total cost and duration of the current session', legacySourcePath: 'old_repo/commands/cost/index.ts', kind: CommandKind.local, handler: cmdCost.run), + CommandSpec(name: 'doctor', description: 'Diagnose and verify your Claude Code installation and settings', legacySourcePath: 'old_repo/commands/doctor/index.ts', handler: cmdDoctor.run), + CommandSpec(name: 'init', description: 'Initialize a new THE_AGENCY.md file with codebase documentation', legacySourcePath: 'old_repo/commands/init.ts', kind: CommandKind.prompt, handler: cmdInit.run), + CommandSpec(name: 'login', description: 'Configure your OpenRouter API key (set in settings)', legacySourcePath: 'old_repo/commands/login/index.ts', handler: cmdLogin.run), + CommandSpec(name: 'logout', description: 'Remove your OpenRouter API key (clear in settings)', legacySourcePath: 'old_repo/commands/logout/index.ts', handler: cmdLogout.run), + CommandSpec(name: 'model', description: 'Set the AI model for Claude Code', legacySourcePath: 'old_repo/commands/model/index.ts', handler: cmdModel.run), + CommandSpec(name: 'permissions', description: 'Manage allow & deny tool permission rules', aliases: ['allowed-tools'], legacySourcePath: 'old_repo/commands/permissions/index.ts', handler: cmdPermissions.run), + CommandSpec(name: 'stats', description: 'Show CLI usage statistics and activity', legacySourcePath: 'old_repo/commands/stats/index.ts', handler: cmdStats.run), + CommandSpec(name: 'statusline', description: "Set up Claude Code's status line UI", legacySourcePath: 'old_repo/commands/statusline.tsx', kind: CommandKind.prompt, handler: cmdStatusline.run), + CommandSpec(name: 'upgrade', description: 'Upgrade to Max for higher rate limits and more Opus', legacySourcePath: 'old_repo/commands/upgrade/index.ts', handler: cmdUpgrade.run), + CommandSpec(name: 'usage', description: 'Show plan usage limits', legacySourcePath: 'old_repo/commands/usage/index.ts', handler: cmdUsage.run), + CommandSpec(name: 'tag', description: 'Toggle a searchable tag on the current session', legacySourcePath: 'old_repo/commands/tag/index.ts', kind: CommandKind.local, handler: cmdTag.run), + CommandSpec(name: 'env', description: 'Show relevant environment variables for this session', legacySourcePath: 'old_repo/commands/env/index.js', kind: CommandKind.local, handler: cmdEnv.run), + CommandSpec(name: 'files', description: 'List all files currently in context', legacySourcePath: 'old_repo/commands/files/index.ts', kind: CommandKind.local, handler: cmdFiles.run), + CommandSpec(name: 'branch', description: 'Create a branch of the current conversation at this point', aliases: ['fork'], legacySourcePath: 'old_repo/commands/branch/index.ts', handler: cmdBranch.run), + CommandSpec(name: 'export', description: 'Export the current conversation to a file or clipboard', legacySourcePath: 'old_repo/commands/export/index.ts', handler: cmdExport.run), + CommandSpec(name: 'memory', description: 'Edit Claude memory files', legacySourcePath: 'old_repo/commands/memory/index.ts', handler: cmdMemory.run), + CommandSpec(name: 'diff', description: 'Show a diff of changes made in this conversation', legacySourcePath: 'old_repo/commands/diff/index.ts', handler: cmdDiff.run), + CommandSpec(name: 'rename', description: 'Rename the current conversation', legacySourcePath: 'old_repo/commands/rename/index.ts', handler: cmdRename.run), + CommandSpec(name: 'copy', description: "Copy Claude's last response to clipboard", legacySourcePath: 'old_repo/commands/copy/index.ts', handler: cmdCopy.run), + CommandSpec(name: 'keybindings', description: 'Open the keybindings config file in your editor', legacySourcePath: 'old_repo/commands/keybindings/index.ts', handler: cmdKeybindings.run), + CommandSpec(name: 'add-dir', description: 'Add a new working directory to the session', legacySourcePath: 'old_repo/commands/add-dir/index.ts', handler: cmdAddDir.run), + CommandSpec(name: 'brief', description: 'Toggle brief-only mode', legacySourcePath: 'old_repo/commands/brief.ts', kind: CommandKind.local, handler: cmdBrief.run), + CommandSpec(name: 'context', description: 'Show current context window usage', legacySourcePath: 'old_repo/commands/context/index.ts', kind: CommandKind.local, handler: cmdContext.run), + CommandSpec(name: 'compact', description: 'Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]', legacySourcePath: 'old_repo/commands/compact/index.ts', kind: CommandKind.local, handler: cmdCompact.run), + CommandSpec(name: 'resume', description: 'Resume a previous conversation', aliases: ['continue'], legacySourcePath: 'old_repo/commands/resume/index.ts', handler: cmdResume.run), + CommandSpec(name: 'review', description: 'Review a pull request', legacySourcePath: 'old_repo/commands/review.ts', kind: CommandKind.prompt, handler: cmdReview.run), + CommandSpec(name: 'hooks', description: 'View hook configurations for tool events', legacySourcePath: 'old_repo/commands/hooks/index.ts', handler: cmdHooks.run), + CommandSpec(name: 'privacy-settings', description: 'View and update your privacy settings', legacySourcePath: 'old_repo/commands/privacy-settings/index.ts', handler: cmdPrivacySettings.run), + CommandSpec(name: 'release-notes', description: 'View release notes', legacySourcePath: 'old_repo/commands/release-notes/index.ts', kind: CommandKind.local, handler: cmdReleaseNotes.run), + CommandSpec(name: 'feedback', description: 'Submit feedback about Claude Code', aliases: ['bug'], legacySourcePath: 'old_repo/commands/feedback/index.ts', handler: cmdFeedback.run), + CommandSpec(name: 'pr-comments', description: 'Get comments from a GitHub pull request', legacySourcePath: 'old_repo/commands/pr_comments/index.ts', kind: CommandKind.prompt, handler: cmdPrComments.run), + CommandSpec(name: 'commit', description: 'Create a git commit', legacySourcePath: 'old_repo/commands/commit.ts', kind: CommandKind.prompt, handler: cmdCommit.run), + CommandSpec(name: 'lint', description: 'Run linting on the current project', legacySourcePath: 'old_repo/commands/lint', kind: CommandKind.local, handler: cmdLint.run), + CommandSpec(name: 'mcp', description: 'Manage MCP servers', legacySourcePath: 'old_repo/commands/mcp/index.ts', handler: cmdMcp.run), + CommandSpec(name: 'advisor', description: 'Configure the advisor model', legacySourcePath: 'old_repo/commands/advisor.ts', kind: CommandKind.local, handler: cmdAdvisor.run), + CommandSpec(name: 'bughunter', description: 'Toggle bug hunter mode', legacySourcePath: 'old_repo/commands/bughunter/index.js', kind: CommandKind.local, handler: cmdBughunter.run), + CommandSpec(name: 'terminal-setup', description: 'Install terminal key bindings for newlines', legacySourcePath: 'old_repo/commands/terminalSetup/index.ts', handler: cmdTerminalSetup.run), + CommandSpec(name: 'install-github-app', description: 'Set up Claude GitHub Actions for a repository', legacySourcePath: 'old_repo/commands/install-github-app/index.ts', handler: cmdInstallGithubApp.run), + CommandSpec(name: 'desktop', description: 'Continue the current session in Claude Desktop', aliases: ['app'], legacySourcePath: 'old_repo/commands/desktop/index.ts', handler: cmdDesktop.run), + CommandSpec(name: 'mobile', description: 'Show QR code to download the Claude mobile app', aliases: ['ios', 'android'], legacySourcePath: 'old_repo/commands/mobile/index.ts', handler: cmdMobile.run), + CommandSpec(name: 'chrome', description: 'Claude in Chrome (Beta) settings', legacySourcePath: 'old_repo/commands/chrome/index.ts', handler: cmdChrome.run), + CommandSpec(name: 'ide', description: 'Manage IDE integrations and show status', legacySourcePath: 'old_repo/commands/ide/index.ts', handler: cmdIde.run), + CommandSpec(name: 'agents', description: 'Manage agent configurations', legacySourcePath: 'old_repo/commands/agents/index.ts', handler: cmdAgents.run), + CommandSpec(name: 'tasks', description: 'List and manage background tasks', aliases: ['bashes'], legacySourcePath: 'old_repo/commands/tasks/index.ts', handler: cmdTasks.run), + CommandSpec(name: 'stickers', description: 'Order Claude Code stickers', legacySourcePath: 'old_repo/commands/stickers/index.ts', kind: CommandKind.local, handler: cmdStickers.run), + CommandSpec(name: 'voice', description: 'Toggle voice mode', legacySourcePath: 'old_repo/commands/voice/index.ts', kind: CommandKind.local, handler: cmdVoice.run), + CommandSpec(name: 'btw', description: 'Ask a quick side question without interrupting the main conversation', legacySourcePath: 'old_repo/commands/btw/index.ts', handler: cmdBtw.run), + CommandSpec(name: 'rewind', description: 'Restore the code and/or conversation to a previous point', aliases: ['checkpoint'], legacySourcePath: 'old_repo/commands/rewind/index.ts', kind: CommandKind.local, handler: cmdRewind.run), + CommandSpec(name: 'plugin', description: 'Manage Claude Code plugins', aliases: ['plugins', 'marketplace'], legacySourcePath: 'old_repo/commands/plugin/index.tsx', handler: cmdPlugin.run), + CommandSpec(name: 'session', description: 'Show remote session URL and QR code', aliases: ['remote'], legacySourcePath: 'old_repo/commands/session/index.ts', handler: cmdSession.run), + CommandSpec(name: 'skills', description: 'List available skills', legacySourcePath: 'old_repo/commands/skills/index.ts', handler: cmdSkills.run), + CommandSpec(name: 'ps', description: 'List background Claude sessions', legacySourcePath: 'old_repo/commands/session/index.ts', handler: cmdPs.run), + CommandSpec(name: 'logs', description: 'Show logs for a background session', legacySourcePath: 'old_repo/commands/session/index.ts', handler: cmdLogs.run), + CommandSpec(name: 'attach', description: 'Attach (tail logs) to a background session', legacySourcePath: 'old_repo/commands/session/index.ts', handler: cmdAttach.run), + CommandSpec(name: 'kill', description: 'Kill a background session', legacySourcePath: 'old_repo/commands/session/index.ts', handler: cmdKill.run), + CommandSpec(name: 'commit-push-pr', description: 'Commit, push, and open a PR', legacySourcePath: 'old_repo/commands/commit-push-pr.ts', kind: CommandKind.prompt, handler: cmdCommitPushPr.run), + CommandSpec(name: 'init-verifiers', description: 'Create verifier skill(s) for automated verification of code changes', legacySourcePath: 'old_repo/commands/init-verifiers.ts', kind: CommandKind.prompt, handler: cmdInitVerifiers.run), + CommandSpec(name: 'security-review', description: 'Complete a security review of the pending changes on the current branch', legacySourcePath: 'old_repo/commands/security-review.ts', kind: CommandKind.prompt, handler: cmdSecurityReview.run), + CommandSpec(name: 'tools', description: 'List all registered tools with their descriptions', legacySourcePath: 'old_repo/commands/tools.ts', kind: CommandKind.local, handler: cmdTools.run), ], reservedTopLevelEntryPoints: legacyTopLevelEntryPoints, ); @@ -589,36 +217,31 @@ class _ClawdCli { _toolRegistry.setSettings(settingsStore.settings); } - Future _initializeServices() async { - try { - // Initialize analytics - final analytics = AnalyticsService(); - final telemetryEnabled = settingsStore.settings.telemetry == 'true' || - settingsStore.settings.telemetry == null; // Default to enabled - await analytics.initialize(enabled: telemetryEnabled); - - // Initialize usage tracking - final usage = UsageTracker(); - await usage.initialize(enabled: true); - - // Log session start - await analytics.logSession(sessionState.sessionId ?? 'unknown', 'start'); - } catch (e) { - // Silently fail - services are optional - } - } - final CommandCatalog catalog; final RuntimeStateStore runtimeStateStore; final SessionState sessionState; final SettingsStore settingsStore; final HookRunner hookRunner; - // tool registry for direct tool invocations like "bash: echo hello" late final ToolRegistry _toolRegistry; + Future _initializeServices() async { + try { + final analytics = AnalyticsService(); + final telemetryEnabled = settingsStore.settings.telemetry == 'true' || + settingsStore.settings.telemetry == null; + await analytics.initialize(enabled: telemetryEnabled); + + final usage = UsageTracker(); + await usage.initialize(enabled: true); + + await analytics.logSession(sessionState.sessionId ?? 'unknown', 'start'); + } catch (e) { + // Silently fail - services are optional + } + } + Future run(List args) async { - // Initialize services await _initializeServices(); if (_isVersionFastPath(args)) { @@ -628,23 +251,14 @@ class _ClawdCli { if (_isHelpFastPath(args)) { final helpArgs = args.length > 1 ? args.sublist(1) : const []; - return _executePortedCommand( - 'help', - helpArgs, - surface: InvocationSurface.topLevel, - interactive: false, - ); + return _executePortedCommand('help', helpArgs, surface: InvocationSurface.topLevel, interactive: false); } - if (args.isEmpty) { - return _startRepl(); - } + if (args.isEmpty) return _startRepl(); return _dispatchTokens( args, - surface: args.first.startsWith('/') - ? InvocationSurface.slash - : InvocationSurface.topLevel, + surface: args.first.startsWith('/') ? InvocationSurface.slash : InvocationSurface.topLevel, interactive: false, ); } @@ -654,46 +268,30 @@ class _ClawdCli { required InvocationSurface surface, required bool interactive, }) async { - if (tokens.isEmpty) { - return const CommandResult(); - } + if (tokens.isEmpty) return const CommandResult(); final normalizedTokens = List.from(tokens); var commandToken = normalizedTokens.first; - if (surface == InvocationSurface.slash) { - commandToken = commandToken.substring(1); - } + if (surface == InvocationSurface.slash) commandToken = commandToken.substring(1); final args = normalizedTokens.sublist(1); if (surface == InvocationSurface.topLevel) { final reserved = catalog.findReservedTopLevel(commandToken); - if (reserved != null) { - return _reportUnported(reserved, interactive: interactive); - } + if (reserved != null) return _reportUnported(reserved, interactive: interactive); } final ported = catalog.findPorted(commandToken, surface); - if (ported != null) { - return _execute(ported, args, surface: surface, interactive: interactive); - } + if (ported != null) return _execute(ported, args, surface: surface, interactive: interactive); final legacy = catalog.findLegacy(commandToken, surface); - if (legacy != null) { - return _reportUnported(legacy, interactive: interactive); - } + if (legacy != null) return _reportUnported(legacy, interactive: interactive); if (surface == InvocationSurface.slash) { - stderr.writeln( - 'Unknown slash command "/$commandToken". Run /help to see the migrated surface.', - ); + stderr.writeln('Unknown slash command "/$commandToken". Run /help to see the migrated surface.'); return const CommandResult(exitCode: 64); } - // Free-form prompt: send to model via REPL handler - return await _handleFreeFormPrompt( - input: tokens.join(' '), - interactive: interactive, - ); + return await _handleFreeFormPrompt(input: tokens.join(' '), interactive: interactive); } Future _execute( @@ -717,10 +315,8 @@ class _ClawdCli { ); }); - // Log command execution await _logCommandExecution(command.name, args); - // run before-command hooks await hookRunner.runHooksForKind( HookKind.userPromptSubmit, targetName: command.name, @@ -742,7 +338,6 @@ class _ClawdCli { final result = await command.handler(context, args); - // run after-command hooks await hookRunner.runHooksForKind( HookKind.stop, targetName: command.name, @@ -759,7 +354,7 @@ class _ClawdCli { final analytics = AnalyticsService(); await analytics.logCommand(commandName, args: args, sessionId: sessionState.sessionId); } catch (e) { - // Silently fail - analytics is optional + // analytics is optional } } @@ -770,10 +365,7 @@ class _ClawdCli { required bool interactive, }) async { final command = catalog.findPorted(name, surface); - if (command == null) { - throw StateError('Ported command "$name" is missing from the catalog.'); - } - + if (command == null) throw StateError('Ported command "$name" is missing from the catalog.'); return _execute(command, args, surface: surface, interactive: interactive); } @@ -788,34 +380,21 @@ class _ClawdCli { ? ' The name was inferred from the legacy file path because the checked-in JS stub does not expose the original metadata.' : ''; - stderr.writeln( - 'Legacy entrypoint "${descriptor.name}" is known but not ported yet.$aliasSuffix', - ); + stderr.writeln('Legacy entrypoint "${descriptor.name}" is known but not ported yet.$aliasSuffix'); stderr.writeln('Source: ${descriptor.legacySourcePath}'); stderr.writeln('Surface: ${descriptor.surface.label}.$inferredSuffix'); - if (interactive) { - stderr.writeln('Run /status to inspect current migration coverage.'); - } - + if (interactive) stderr.writeln('Run /status to inspect current migration coverage.'); return const CommandResult(exitCode: 2); } - bool _isHelpFastPath(List args) { - if (args.isEmpty) { - return false; - } + bool _isHelpFastPath(List args) => + args.isNotEmpty && (args.first == '--help' || args.first == '-h'); - return args.first == '--help' || args.first == '-h'; - } + bool _isVersionFastPath(List args) => + args.length == 1 && + (args.first == '--version' || args.first == '-v' || args.first == '-V'); - bool _isVersionFastPath(List args) { - return args.length == 1 && - (args.first == '--version' || args.first == '-v' || args.first == '-V'); - } - - // if input looks like "toolname: some args", dispatch to ToolRegistry and return result - // returns null if the input doesnt match the pattern Future _maybeDispatchToolInvocation(String input) async { if (input.startsWith('/')) return null; @@ -823,15 +402,17 @@ class _ClawdCli { if (colonIdx <= 0) return null; final toolName = input.substring(0, colonIdx).trim().toLowerCase(); - - // only match if the tool actually exists if (_toolRegistry.getTool(toolName) == null) return null; - final toolArgs = input.substring(colonIdx + 1).trim(); try { - final output = await _toolRegistry.execute(toolName, {"input": toolArgs, "command": toolArgs, "path": toolArgs, "pattern": toolArgs}); + final output = await _toolRegistry.execute(toolName, { + "input": toolArgs, + "command": toolArgs, + "path": toolArgs, + "pattern": toolArgs, + }); stdout.writeln(output); } catch (e) { stderr.writeln("Tool error: $e"); @@ -844,13 +425,11 @@ class _ClawdCli { await runtimeStateStore.update((current) { return current.copyWith( stats: current.stats.copyWith( - interactiveSessionsStarted: - current.stats.interactiveSessionsStarted + 1, + interactiveSessionsStarted: current.stats.interactiveSessionsStarted + 1, ), ); }); - // load user keybindings from ~/.claude/keybindings.json final keybindings = loadKeybindings(); stdout.writeln('${BuildInfo.packageName} ${BuildInfo.versionDisplay}'); @@ -866,11 +445,8 @@ class _ClawdCli { } final trimmed = line.trim(); - if (trimmed.isEmpty) { - continue; - } + if (trimmed.isEmpty) continue; - // check if input matches a custom keybinding final boundAction = resolveKeybinding(keybindings, trimmed, KeyContext.chat) ?? resolveKeybinding(keybindings, trimmed, KeyContext.global_); if (boundAction != null) { @@ -891,7 +467,6 @@ class _ClawdCli { } } - // check for tool invocation syntax like "bash: echo hello" final toolResult = await _maybeDispatchToolInvocation(trimmed); if (toolResult != null) { if (toolResult.exitRepl) { @@ -902,14 +477,8 @@ class _ClawdCli { } final tokens = _tokenize(trimmed); - final surface = trimmed.startsWith('/') - ? InvocationSurface.slash - : InvocationSurface.topLevel; - final result = await _dispatchTokens( - tokens, - surface: surface, - interactive: true, - ); + final surface = trimmed.startsWith('/') ? InvocationSurface.slash : InvocationSurface.topLevel; + final result = await _dispatchTokens(tokens, surface: surface, interactive: true); if (result.exitRepl) { _persistCostState(); @@ -935,10 +504,7 @@ class _ClawdCli { ); stdout.writeln(''); - await handler.executePrompt( - userInput: input, - streaming: true, - ); + await handler.executePrompt(userInput: input, streaming: true); stdout.writeln(''); return const CommandResult(); @@ -952,8 +518,6 @@ class _ClawdCli { } } - -// persist session cost state on exit — mirrors saveCurrentSessionCosts from old_repo void _persistCostState() { try { final home = Platform.environment["HOME"]; @@ -973,8 +537,7 @@ void _persistCostState() { "totalLinesAdded": costTracker.getTotalLinesAdded(), "totalLinesRemoved": costTracker.getTotalLinesRemoved(), "modelUsage": { - for (final e in costTracker.getModelUsage().entries) - e.key: e.value.toJson(), + for (final e in costTracker.getModelUsage().entries) e.key: e.value.toJson(), }, }; @@ -984,2956 +547,6 @@ void _persistCostState() { } } -Future _runClear( - CommandContext context, - List args, -) async { - context.out.write('\x1B[2J\x1B[H'); - return const CommandResult(); -} - -Future _runColor( - CommandContext context, - List 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 (_colorResetAliases.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(); -} - -Future _runConfig( - CommandContext context, - List 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(); -} - -Future _runCost( - CommandContext context, - List args, -) async { - context.writeLine(costTracker.formatTotalCost()); - return const CommandResult(); -} - -Future _runDoctor( - CommandContext context, - List args, -) async { - final workingDirectory = Directory(context.workingDirectory); - final legacyRoot = Directory(joinPath(context.workingDirectory, 'old_repo')); - final claudeMdFile = File(joinPath(context.workingDirectory, 'CLAUDE.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] CLAUDE.md: ${claudeMdFile.path} (${length.toString()} bytes)', - ); - } else { - context.writeLine('[warn] CLAUDE.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(); -} - -Future _runEffort( - CommandContext context, - List args, -) async { - final rawArgs = args.join(' ').trim(); - if (_commonHelpArgs.contains(rawArgs)) { - context.writeLine(_effortHelpText); - 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(); -} - -Future _runExit( - CommandContext context, - List args, -) async { - return const CommandResult(exitRepl: true); -} - -Future _runFast( - CommandContext context, - List 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(); -} - -Future _runHelp( - CommandContext context, - List 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 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(); -} - -Future _runInit( - CommandContext context, - List args, -) async { - final command = args.isEmpty ? 'write' : args.first.toLowerCase(); - final force = args.any((arg) => arg == '--force' || arg == 'force'); - final claudeMdPath = joinPath(context.workingDirectory, 'CLAUDE.md'); - final targetFile = File(claudeMdPath); - final draft = await _buildClaudeMdDraft(context.workingDirectory); - - if (command == 'preview' || command == 'show') { - context.writeLine(draft); - return const CommandResult(); - } - - if (!force && await targetFile.exists()) { - context.writeLine('CLAUDE.md already exists at $claudeMdPath'); - context.writeLine( - 'Run /init preview to inspect the regenerated draft or /init force to overwrite it.', - ); - return const CommandResult(); - } - - await targetFile.writeAsString('$draft\n'); - context.writeLine('Wrote $claudeMdPath'); - return const CommandResult(); -} - -Future _runLogin( - CommandContext context, - List 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(); -} - -Future _runLogout( - CommandContext context, - List args, -) async { - context.writeLine('To remove your OpenRouter API key, clear it in Settings.'); - return const CommandResult(); -} - - -Future _runTools( - CommandContext context, - List 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: (e.g. bash: echo hello)'); - return const CommandResult(); -} - -Future _runModel( - CommandContext context, - List args, -) async { - final rawArgs = args.join(' ').trim(); - if (_commonHelpArgs.contains(rawArgs.toLowerCase())) { - context.writeLine('Usage: /model [default|current|status|]'); - 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(); -} - -Future _runOutputStyle( - CommandContext context, - List 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(); -} - -Future _runPermissions( - CommandContext context, - List args, -) async { - if (args.isEmpty) { - _writePermissionsSummary(context); - return const CommandResult(); - } - - final subcommand = args.first.toLowerCase(); - if (_commonHelpArgs.contains(subcommand)) { - context.writeLine( - 'Usage: /permissions [show|mode |allow |deny |ask |remove |clear [allow|deny|ask|all]]', - ); - context.writeLine('Modes: ${supportedPermissionModes.join(', ')}'); - return const CommandResult(); - } - - if (subcommand == 'show' || - subcommand == 'list' || - _commonInfoArgs.contains(subcommand)) { - _writePermissionsSummary(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) => _applyPermissionRule(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 (!['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) => _clearPermissionRules(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 '); - return const CommandResult(exitCode: 64); - } - - final removal = _removePermissionRule( - 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); -} - -Future _runPlan( - CommandContext context, - List 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 [] - : 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(); -} - -Future _runStats( - CommandContext context, - List 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(); -} - -Future _runStatus( - CommandContext context, - List args, -) async { - final unportedCommands = context.catalog.unportedSlashCommands; - final sample = unportedCommands - .take(10) - .map((command) => command.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: ${_totalPermissionRuleCount(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(); -} - -Future _runStatusline( - CommandContext context, - List 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|]'); - 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(); -} - -Future _runTheme( - CommandContext context, - List 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(); -} - -Future _runUpgrade( - CommandContext context, - List 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(); -} - -Future _runUsage( - CommandContext context, - List 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(); -} - -Future _runVersion( - CommandContext context, - List args, -) async { - context.writeLine(BuildInfo.versionDisplay); - return const CommandResult(); -} - -Future _runVim(CommandContext context, List 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(); -} - -Future _runTag( - CommandContext context, - List args, -) async { - final tagArg = args.join(" ").trim(); - - if (tagArg.isEmpty || _commonHelpArgs.contains(tagArg) || _commonInfoArgs.contains(tagArg)) { - context.writeLine( - 'Usage: /tag \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(); - } - - // sanitize basic control chars - 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(); -} - - -Future _runEnv( - CommandContext context, - List args, -) async { - // env command was stubbed/disabled in legacy (isEnabled: () => false) - // we show the relevant env vars anyway - more useful than nothing - const relevantKeys = [ - 'OPENROUTER_API_KEY', - 'CLAUDE_CODE_EFFORT_LEVEL', - 'CLAUDE_CODE_SKIP_PERMISSIONS_CHECK', - 'EDITOR', - 'VISUAL', - 'HOME', - 'PATH', - 'SHELL', - 'USER', - 'USER_TYPE', - ]; - - context.writeLine("Environment variables:"); - for (final key in relevantKeys) { - final val = Platform.environment[key]; - if (val != null) { - // mask the API key - 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(); -} - -Future _runFiles( - CommandContext context, - List args, -) async { - // In the ported CLI we dont have a live readFileState cache yet, - // so just say so rather than lie about it - context.writeLine("No files in context"); - context.writeLine( - "(Note: file context tracking is not yet ported to the Dart CLI runtime)", - ); - - return const CommandResult(); -} - -Future _runBranch( - CommandContext context, - List args, -) async { - final customTitle = args.join(" ").trim(); - - if (!_history.hasSession) { - context.writeLine("No active session to branch from."); - return const CommandResult(exitCode: 1); - } - - // fork the curent session into a new one with a fresh ID - 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(); -} - -Future _runExport( - CommandContext context, - List 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) { - // just dump to stdout - 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(); -} - - -Future _runMemory( - CommandContext context, - List args, -) async { - // find the claude config home dir - same logic as the legacy getClaudeConfigHomeDir - final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? ''; - final claudeHome = joinPath(home, '.claude'); - - final globalMemoryPath = joinPath(claudeHome, 'CLAUDE.md'); - final localMemoryPath = joinPath(context.workingDirectory, 'CLAUDE.md'); - - final rawArgs = args.join(" ").trim().toLowerCase(); - - if (_commonHelpArgs.contains(rawArgs)) { - context.writeLine( - 'Usage: /memory [global|local]\n\n' - 'Edit Claude memory files (CLAUDE.md).\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); -} - -Future _runDiff( - CommandContext context, - List args, -) async { - // run git diff in the working directory - 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(); -} - -Future _runRename( - CommandContext context, - List args, -) async { - final newName = args.join(" ").trim(); - - if (newName.isEmpty || _commonHelpArgs.contains(newName.toLowerCase())) { - context.writeLine( - 'Usage: /rename \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; - - // also persist to disk if we have an active session - if (_history.hasSession) { - _history.session!.name = newName; - await SessionStore.instance.saveSession(_history.session!); - } - - context.writeLine('Session renamed to: "$newName"'); - - return const CommandResult(); -} - -Future _runCopy( - CommandContext context, - List args, -) async { - if (!_history.hasSession) { - context.writeLine("No active session - nothing to copy."); - return const CommandResult(exitCode: 1); - } - - // find the last assistant message - 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); - } - - // default to the last one, or allow an index arg - 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(); -} - -Future _runKeybindings( - CommandContext context, - List args, -) async { - final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? ''; - final keybindingsPath = joinPath(joinPath(home, '.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) { - // create parent dirs + write a starter template - await Directory(joinPath(home, '.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(); -} - -Future _runAddDir( - CommandContext context, - List args, -) async { - final dirArg = args.join(" ").trim(); - - if (dirArg.isEmpty || _commonHelpArgs.contains(dirArg.toLowerCase())) { - context.writeLine( - 'Usage: /add-dir \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(); - } - - // resolve relative paths against cwd - 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(); -} - -Future _runBrief( - CommandContext context, - List args, -) async { - // brief mode toggles a session flag - not fully wired to model calls yet - final current = context.sessionState.briefModeEnabled; - final newState = !current; - - context.sessionState.briefModeEnabled = newState; - - context.writeLine( - newState ? 'Brief-only mode enabled' : 'Brief-only mode disabled', - ); - - return const CommandResult(); -} - - -Future _runContext( - CommandContext context, - List args, -) async { - // the actual token counts arent available in this runtime yet - // but we can at least show some useful session info - 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(); -} - -Future _runCompact( - CommandContext context, - List args, -) async { - final instructions = args.join(' ').trim(); - - // no live message history in the dart runtime, so we cant actually compact - 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); -} - - -Future _runResume( - CommandContext context, - List 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(); - } - - // if query provided, filter by name or id - 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 ", - ); - - // if exactly one match and it was a direct lookup - load it - if (filtered.length == 1 && query.isNotEmpty) { - final loaded = await SessionStore.instance.loadSession(filtered.first.id, workingDirectory: context.workingDirectory); - if (loaded != null) { - _history.setSession(loaded); - context.sessionState.sessionName = loaded.name; - context.writeLine('\nResumed session: "${loaded.name}"'); - } - } - - return const CommandResult(); -} - -Future _runReview( - CommandContext context, - List args, -) async { - final prArg = args.join(' ').trim(); - - // prompt-type command in legacy - we surface the prompt text here - 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); -} - - -Future _runHooks( - CommandContext context, - List args, -) async { - final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? ''; - final hooksConfigPath = joinPath(joinPath(home, '.claude'), 'settings.json'); - - 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' - ' $hooksConfigPath', - ); - return const CommandResult(); - } - - // show current hooks from settings - 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(); -} - -Future _runPrivacySettings( - CommandContext context, - List 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(); -} - -Future _runReleaseNotes( - CommandContext context, - List args, -) async { - // in the legcy CLI this fetches from a remote changelog URL and caches it - // we dont have network access wired up here so just show the version + link - - 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(); -} - - -Future _runFeedback( - CommandContext context, - List 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); -} - -Future _runPrComments( - CommandContext context, - List 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); -} - - -Future _runCommit( - CommandContext context, - List args, -) async { - // prompt-type in legacy - sends a full commit prompt to the model - // in our runtime we can at least show git status as a useful shortcut - - 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); -} - -Future _runLint( - CommandContext context, - List args, -) async { - // detect what kind of project we're in and run the appropriate linter - final rawArgs = args.join(' ').trim(); - - final pubspecFile = File(joinPath(context.workingDirectory, 'pubspec.yaml')); - final packageJsonFile = File(joinPath(context.workingDirectory, 'package.json')); - - if (_commonHelpArgs.contains(rawArgs.toLowerCase())) { - context.writeLine('Usage: /lint\n\nRun the project linter.'); - return const CommandResult(); - } - - List 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); - } -} - - -Future _runMcp( - CommandContext context, - List 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 [args...]|remove |enable |disable ]\n\n' - 'Manage MCP (Model Context Protocol) servers.', - ); - return const CommandResult(); - } - - final servers = Map>.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 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; - final status = disabled ? ' [disabled]' : ''; - context.writeLine(' ${entry.key}: $cmd$status'); - } - } - return const CommandResult(); - } - - if (sub == 'add') { - // /mcp add [args...] - if (parts.length < 3) { - context.writeLine('Usage: /mcp add [args...]'); - return const CommandResult(exitCode: 64); - } - - final name = parts[1]; - final command = parts[2]; - final cmdArgs = parts.length > 3 ? parts.sublist(3) : []; - - servers[name] = { - '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 '); - 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.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.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); -} - - -Future _runAdvisor( - CommandContext context, - List 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 " to enable (e.g. "/advisor opus").', - ); - } else { - context.writeLine( - 'Advisor: $current\nUse "/advisor unset" to disable or "/advisor " 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 [|off]\n\nSet the advisor model for the session.'); - return const CommandResult(); - } - - // set advisor model - context.sessionState.advisorModel = arg; - await context.settingsStore.update((s) => s.copyWith(advisorModel: arg)); - context.writeLine('Advisor set to $arg.'); - return const CommandResult(); -} - - -Future _runBughunter( - CommandContext context, - List args, -) async { - // bughunter was disabled/hidden in old_repo (index.js returns isEnabled: false) - // we still port it as a proper toggle that stores session state - 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(); -} - - -Future _runTerminalSetup( - CommandContext context, - List args, -) async { - // in the legacy CLI this runs an interactive wizard to install shell bindings - // for Shift+Enter / Option+Enter depending on terminal type. - // We cant do the actual key binding install in the dart runtime, but we - // can detect the terminal and give the right instructions. - - final term = Platform.environment['TERM_PROGRAM'] ?? Platform.environment['TERMINAL_EMULATOR'] ?? ''; - final isMac = Platform.isMacOS; - - - const nativeCsiuTerminals = [ - 'ghostty', 'kitty', 'iTerm.app', 'WezTerm', 'WarpTerminal', - ]; - - 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 (isMac && 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(); -} - - -Future _runInstallGithubApp( - CommandContext context, - List args, -) async { - // full interactive wizard isn't ported - opens the docs URL instead - 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(''); - - // check if gh cli is available and show current repo as a hint - 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); -} - - -Future _runDesktop( - CommandContext context, - List args, -) async { - // the legacy version launches Claude Desktop and hands off the session - // via a URL scheme or file-based handoff. - // We can't do the actual handoff in the Dart runtime. - - final isMac = Platform.isMacOS; - final isWin = Platform.isWindows; - - if (!isMac && !isWin) { - 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); -} - - -Future _runMobile( - CommandContext context, - List args, -) async { - // the legacy version renders a QR code in the terminal using qrcode npm package - // we cant render a full QR in the terminal easily - just show the links - - 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(); - - String url; - String platform; - - if (arg == 'android') { - url = androidUrl; - platform = 'Android'; - } else { - // default to ios - url = iosUrl; - platform = '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(); -} - - -Future _runChrome( - CommandContext context, - List 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(); -} - - -Future _runIde( - CommandContext context, - List args, -) async { - // in the legacy CLI this shows a dialog with IDE picker and auto-connect settings - // we detect VSCODE/cursor/windsurf from env vars and show status - - 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(); -} - - -Future _runAgents( - CommandContext context, - List 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); -} - - -Future _runTasks( - CommandContext context, - List args, -) async { - // legacy shows a live-updating list of background bash tasks running in the session - 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); -} - -Future _runStickers( - CommandContext context, - List args, -) async { - const url = "https://www.stickermule.com/claudecode"; - - // try to open browser - final platform = Platform.operatingSystem; - String? browserCmd; - - if (platform == "macos") { - browserCmd = "open"; - } else if (platform == "linux") { - browserCmd = "xdg-open"; - } else if (platform == "windows") { - browserCmd = "start"; - } - - bool opened = false; - if (browserCmd != null) { - try { - final result = await Process.run(browserCmd, [url]); - opened = result.exitCode == 0; - } catch (_) { - // noop - } - } - - if (opened) { - context.writeLine("Opening sticker page in browser..."); - } else { - context.writeLine("Order Claude Code stickers at: $url"); - } - - return const CommandResult(); -} - -Future _runVoice( - CommandContext context, - List 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); -} - -Future _runBtw( - CommandContext context, - List args, -) async { - final question = args.join(" ").trim(); - - context.writeLine("Side Question (btw)"); - context.writeLine(""); - - if (question.isEmpty) { - context.writeLine("Usage: /btw "); - 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); -} - - -Future _runRewind( - CommandContext context, - List args, -) async { - // in legacy this opens an interactive checkpoint selector in the REPL - 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); -} - -Future _runPlugin( - CommandContext context, - List 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 "); - } 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); -} - -Future _runSession( - CommandContext context, - List args, -) async { - // remote session/QR code - only relevant in remote mode - 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); -} - -Future _runSkills( - CommandContext context, - List 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); -} - - -// ─── daemon session subcommands ─────────────────────────────────────────── - -Future _runPs( - CommandContext context, - List 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); -} - - -Future _runLogs( - CommandContext context, - List args, -) async { - if (args.isEmpty) { - context.writeLine("Usage: /logs [--tail N]"); - return const CommandResult(exitCode: 1); - } - - final id = args[0]; - int? tail; - - // parse --tail N - 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); -} - - -// stream logs until session ends or user cancels -Future _runAttach( - CommandContext context, - List args, -) async { - if (args.isEmpty) { - context.writeLine("Usage: /attach "); - 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); -} - - -Future _runKill( - CommandContext context, - List args, -) async { - if (args.isEmpty) { - context.writeLine("Usage: /kill [--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); - } else { - 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); - } -} - - -LocalSettings _applyPermissionRule( - 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, - ); -} - -Future _buildClaudeMdDraft(String workingDirectory) { - return _buildClaudeMdDraftAsync(workingDirectory); -} - -String _buildStatuslineAgentInstruction(String prompt) { - return 'Create an Agent with subagent_type "statusline-setup" and the prompt "$prompt"'; -} - -LocalSettings _clearPermissionRules(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; - } -} - -String _defaultModelForSubscription(String? subscriptionType) { - switch (subscriptionType) { - case 'max': - case 'team-premium': - return 'opus[1m]'; - default: - return 'sonnet'; - } -} - -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 _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'; -} - -bool _supportsFastMode(String model) { - final normalized = model.toLowerCase(); - return normalized.contains('opus') || normalized.contains('sonnet'); -} - -String _normalizeModelInput(String rawModel) { - final trimmed = rawModel.trim(); - final lowered = trimmed.toLowerCase(); - if (_modelAliases.contains(lowered)) { - return lowered; - } - return trimmed; -} - -_PermissionRemovalResult _removePermissionRule( - LocalSettings settings, - String target, -) { - final flattened = _flattenPermissionEntries(settings); - final index = int.tryParse(target); - if (index != null) { - final entryIndex = index - 1; - if (entryIndex < 0 || entryIndex >= flattened.length) { - return _PermissionRemovalResult( - removed: false, - settings: settings, - label: target, - ); - } - - final entry = flattened[entryIndex]; - return _PermissionRemovalResult( - removed: true, - settings: _removePermissionRuleByLabel( - settings, - entry.behavior, - entry.rule, - ), - label: '${entry.behavior} ${entry.rule}', - ); - } - - for (final entry in flattened) { - if (entry.rule == target) { - return _PermissionRemovalResult( - removed: true, - settings: _removePermissionRuleByLabel( - settings, - entry.behavior, - entry.rule, - ), - label: '${entry.behavior} ${entry.rule}', - ); - } - } - - return _PermissionRemovalResult( - removed: false, - settings: settings, - label: target, - ); -} - -LocalSettings _removePermissionRuleByLabel( - 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; - } -} - -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)})'; -} - -int _totalPermissionRuleCount(LocalSettings settings) { - return settings.alwaysAllowRules.length + - settings.alwaysAskRules.length + - settings.alwaysDenyRules.length; -} - -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.'); - } -} - -void _writePermissionsSummary(CommandContext context) { - final settings = context.settingsStore.settings; - final flattened = _flattenPermissionEntries(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}'); - } -} - -Future _buildClaudeMdDraftAsync(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> _collectArchitectureNotes(String workingDirectory) async { - final notes = []; - final binDirectory = Directory(joinPath(workingDirectory, 'bin')); - final libDirectory = Directory(joinPath(workingDirectory, 'lib')); - final oldRepoDirectory = Directory(joinPath(workingDirectory, 'old_repo')); - final testDirectory = Directory(joinPath(workingDirectory, 'test')); - - if (await binDirectory.exists()) { - notes.add('`bin/` contains the executable entrypoints for the Dart CLI.'); - } - if (await libDirectory.exists()) { - notes.add( - '`lib/src/` contains the migrated Dart command/runtime implementation.', - ); - } - if (await oldRepoDirectory.exists()) { - notes.add( - '`old_repo/` is the legacy TypeScript reference implementation being ported 1:1.', - ); - } - if (await testDirectory.exists()) { - notes.add( - '`test/` holds Dart validation coverage for the migrated runtime.', - ); - } - - return notes; -} - -Future> _collectDetectedCommands(String workingDirectory) async { - final commands = []; - 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 testDirectory = Directory(joinPath(workingDirectory, 'test')); - final binDirectory = Directory(joinPath(workingDirectory, 'bin')); - - if (await pubspecFile.exists()) { - commands.add('dart pub get'); - commands.add('dart analyze'); - if (await testDirectory.exists()) { - commands.add('dart test'); - } - if (await binDirectory.exists()) { - final binEntries = await binDirectory - .list() - .where((entity) => entity is File) - .cast() - .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); -} - -List<_PermissionEntry> _flattenPermissionEntries(LocalSettings settings) { - return <_PermissionEntry>[ - ...settings.alwaysAllowRules.map( - (rule) => _PermissionEntry(behavior: 'allow', rule: rule), - ), - ...settings.alwaysAskRules.map( - (rule) => _PermissionEntry(behavior: 'ask', rule: rule), - ), - ...settings.alwaysDenyRules.map( - (rule) => _PermissionEntry(behavior: 'deny', rule: rule), - ), - ]; -} - -Future> _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 = []; - 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 []; - } -} - List _tokenize(String input) { final tokens = []; final current = StringBuffer(); @@ -3941,10 +554,7 @@ List _tokenize(String input) { var escapeNext = false; void flush() { - if (current.isEmpty) { - return; - } - + if (current.isEmpty) return; tokens.add(current.toString()); current.clear(); } @@ -3988,135 +598,3 @@ List _tokenize(String input) { flush(); return tokens; } - -Future _runCommitPushPr( - CommandContext context, - List args, -) async { - // prompt-type command — shows current git state and explains the workflow - 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); -} - - -Future _runInitVerifiers( - CommandContext context, - List 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); -} - - -Future _runSecurityReview( - CommandContext context, - List 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); -} - - -class _PermissionEntry { - const _PermissionEntry({required this.behavior, required this.rule}); - - final String behavior; - final String rule; -} - -class _PermissionRemovalResult { - const _PermissionRemovalResult({ - required this.label, - required this.removed, - required this.settings, - }); - - final String label; - final bool removed; - final LocalSettings settings; -} diff --git a/lib/src/chat/advisor_service.dart b/lib/src/chat/advisor_service.dart index 60d4f98..66a17c1 100644 --- a/lib/src/chat/advisor_service.dart +++ b/lib/src/chat/advisor_service.dart @@ -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 run({ + Future run({ required String advisorModel, required String apiKey, required List> conversationSoFar, + required String systemPrompt, + required List> toolDefinitions, + String? effortLevel, void Function(String toolName, Map 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> 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) continue; + final fn = tc["function"] as Map?; + 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> _stripDanglingToolUse( + List> 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 && b["type"] == "tool_use", + )); + + if (!hasToolUse) return messages; + + return messages.sublist(0, messages.length - 1); +} diff --git a/lib/src/chat/tool_loop_service.dart b/lib/src/chat/tool_loop_service.dart index 38c6d1d..d197b73 100644 --- a/lib/src/chat/tool_loop_service.dart +++ b/lib/src/chat/tool_loop_service.dart @@ -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> 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 advisorUsages; } class ToolLoopException implements Exception { @@ -75,6 +95,7 @@ class ToolLoopService { String? advisorModel, void Function(String toolName, Map 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 Function(String toolName, Map input, {String? suggestionRule})? onPermissionRequired, @@ -115,19 +136,26 @@ class ToolLoopService { } late ApiMessage lastResponse; + final advisorUsages = []; + + // 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({ + "role": "tool", + "tool_call_id": toolUse.id, + "content": denied, + }); + continue; + } + } + final advisorResult = await _advisorService.run( advisorModel: advisorModel!, apiKey: apiKey, conversationSoFar: List>.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({ "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({ "role": "tool", "tool_call_id": toolUse.id, - "content": toolResult, + "content": toolResultContent, }); } } @@ -255,6 +326,8 @@ class ToolLoopService { Future _executeTool({ required ToolUse toolUse, required Map 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: {}, required: const [], ), @@ -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:: → 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 = >[]; + 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; + } } diff --git a/lib/src/commands/_shared.dart b/lib/src/commands/_shared.dart new file mode 100644 index 0000000..0e7d160 --- /dev/null +++ b/lib/src/commands/_shared.dart @@ -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 = ['help', '-h', '--help']; +const commonInfoArgs = ['current', 'info', 'show', 'status']; + +const defaultStatuslinePrompt = + 'Configure my statusLine from my shell PS1 configuration'; + +const max20xTier = 'default_claude_max_20x'; + +const modelAliases = [ + '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'); diff --git a/lib/src/commands/add_dir.dart b/lib/src/commands/add_dir.dart new file mode 100644 index 0000000..5e8339b --- /dev/null +++ b/lib/src/commands/add_dir.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import '../command.dart'; +import '../local_state.dart' show joinPath; +import '_shared.dart'; + +Future run(CommandContext context, List args) async { + final dirArg = args.join(" ").trim(); + + if (dirArg.isEmpty || commonHelpArgs.contains(dirArg.toLowerCase())) { + context.writeLine( + 'Usage: /add-dir \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(); +} diff --git a/lib/src/commands/advisor.dart b/lib/src/commands/advisor.dart new file mode 100644 index 0000000..d00cdaf --- /dev/null +++ b/lib/src/commands/advisor.dart @@ -0,0 +1,40 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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 " to enable (e.g. "/advisor opus").', + ); + } else { + context.writeLine( + 'Advisor: $current\nUse "/advisor unset" to disable or "/advisor " 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 [|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(); +} diff --git a/lib/src/commands/agents.dart b/lib/src/commands/agents.dart new file mode 100644 index 0000000..34e4b4b --- /dev/null +++ b/lib/src/commands/agents.dart @@ -0,0 +1,14 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/attach.dart b/lib/src/commands/attach.dart new file mode 100644 index 0000000..0b32934 --- /dev/null +++ b/lib/src/commands/attach.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import '../command.dart'; +import '../daemon/daemon_manager.dart'; + +Future run(CommandContext context, List args) async { + if (args.isEmpty) { + context.writeLine("Usage: /attach "); + 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); +} diff --git a/lib/src/commands/branch.dart b/lib/src/commands/branch.dart new file mode 100644 index 0000000..9c9ae3f --- /dev/null +++ b/lib/src/commands/branch.dart @@ -0,0 +1,41 @@ +import '../command.dart'; +import '../session/session_store.dart'; +import '../session/session_types.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/brief.dart b/lib/src/commands/brief.dart new file mode 100644 index 0000000..295ceac --- /dev/null +++ b/lib/src/commands/brief.dart @@ -0,0 +1,8 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/btw.dart b/lib/src/commands/btw.dart new file mode 100644 index 0000000..8986846 --- /dev/null +++ b/lib/src/commands/btw.dart @@ -0,0 +1,27 @@ +import '../command.dart'; + +Future run(CommandContext context, List args) async { + final question = args.join(" ").trim(); + + context.writeLine("Side Question (btw)"); + context.writeLine(""); + + if (question.isEmpty) { + context.writeLine("Usage: /btw "); + 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); +} diff --git a/lib/src/commands/bughunter.dart b/lib/src/commands/bughunter.dart new file mode 100644 index 0000000..dc1a764 --- /dev/null +++ b/lib/src/commands/bughunter.dart @@ -0,0 +1,17 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/chrome.dart b/lib/src/commands/chrome.dart new file mode 100644 index 0000000..93a2a28 --- /dev/null +++ b/lib/src/commands/chrome.dart @@ -0,0 +1,17 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/clear.dart b/lib/src/commands/clear.dart new file mode 100644 index 0000000..7b08332 --- /dev/null +++ b/lib/src/commands/clear.dart @@ -0,0 +1,6 @@ +import '../command.dart'; + +Future run(CommandContext context, List args) async { + context.out.write('\x1B[2J\x1B[H'); + return const CommandResult(); +} diff --git a/lib/src/commands/color.dart b/lib/src/commands/color.dart new file mode 100644 index 0000000..5257574 --- /dev/null +++ b/lib/src/commands/color.dart @@ -0,0 +1,29 @@ +import '../command.dart'; +import '../local_state.dart'; + +const _resetAliases = ['default', 'reset', 'none', 'gray', 'grey']; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/commit.dart b/lib/src/commands/commit.dart new file mode 100644 index 0000000..c3807e2 --- /dev/null +++ b/lib/src/commands/commit.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/commit_push_pr.dart b/lib/src/commands/commit_push_pr.dart new file mode 100644 index 0000000..bb0eb25 --- /dev/null +++ b/lib/src/commands/commit_push_pr.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/compact.dart b/lib/src/commands/compact.dart new file mode 100644 index 0000000..fffba74 --- /dev/null +++ b/lib/src/commands/compact.dart @@ -0,0 +1,16 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/config.dart b/lib/src/commands/config.dart new file mode 100644 index 0000000..35a93b2 --- /dev/null +++ b/lib/src/commands/config.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; + +import '../command.dart'; + +const _jsonEncoder = JsonEncoder.withIndent(' '); + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/context.dart b/lib/src/commands/context.dart new file mode 100644 index 0000000..b959e43 --- /dev/null +++ b/lib/src/commands/context.dart @@ -0,0 +1,20 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/copy.dart b/lib/src/commands/copy.dart new file mode 100644 index 0000000..bc7725c --- /dev/null +++ b/lib/src/commands/copy.dart @@ -0,0 +1,33 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/cost.dart b/lib/src/commands/cost.dart new file mode 100644 index 0000000..7146f0e --- /dev/null +++ b/lib/src/commands/cost.dart @@ -0,0 +1,7 @@ +import '../command.dart'; +import '../services/cost_tracker.dart' as costTracker; + +Future run(CommandContext context, List args) async { + context.writeLine(costTracker.formatTotalCost()); + return const CommandResult(); +} diff --git a/lib/src/commands/desktop.dart b/lib/src/commands/desktop.dart new file mode 100644 index 0000000..7a9f289 --- /dev/null +++ b/lib/src/commands/desktop.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/diff.dart b/lib/src/commands/diff.dart new file mode 100644 index 0000000..78d280a --- /dev/null +++ b/lib/src/commands/diff.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/doctor.dart b/lib/src/commands/doctor.dart new file mode 100644 index 0000000..eec23e1 --- /dev/null +++ b/lib/src/commands/doctor.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import '../command.dart'; +import '../local_state.dart' show joinPath; + +const _largeClaudeMdWarningChars = 40000; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/effort.dart b/lib/src/commands/effort.dart new file mode 100644 index 0000000..b8d7164 --- /dev/null +++ b/lib/src/commands/effort.dart @@ -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 run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/env.dart b/lib/src/commands/env.dart new file mode 100644 index 0000000..e1e20dd --- /dev/null +++ b/lib/src/commands/env.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import '../command.dart'; + +const _relevantKeys = [ + 'OPENROUTER_API_KEY', + 'CLAUDE_CODE_EFFORT_LEVEL', + 'CLAUDE_CODE_SKIP_PERMISSIONS_CHECK', + 'EDITOR', + 'VISUAL', + 'HOME', + 'PATH', + 'SHELL', + 'USER', + 'USER_TYPE', +]; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/exit.dart b/lib/src/commands/exit.dart new file mode 100644 index 0000000..ccecbc9 --- /dev/null +++ b/lib/src/commands/exit.dart @@ -0,0 +1,5 @@ +import '../command.dart'; + +Future run(CommandContext context, List args) async { + return const CommandResult(exitRepl: true); +} diff --git a/lib/src/commands/export.dart b/lib/src/commands/export.dart new file mode 100644 index 0000000..50963ad --- /dev/null +++ b/lib/src/commands/export.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/fast.dart b/lib/src/commands/fast.dart new file mode 100644 index 0000000..bedfdd1 --- /dev/null +++ b/lib/src/commands/fast.dart @@ -0,0 +1,31 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/feedback.dart b/lib/src/commands/feedback.dart new file mode 100644 index 0000000..3a47c7f --- /dev/null +++ b/lib/src/commands/feedback.dart @@ -0,0 +1,19 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/files.dart b/lib/src/commands/files.dart new file mode 100644 index 0000000..b39c452 --- /dev/null +++ b/lib/src/commands/files.dart @@ -0,0 +1,9 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/help.dart b/lib/src/commands/help.dart new file mode 100644 index 0000000..0f56e98 --- /dev/null +++ b/lib/src/commands/help.dart @@ -0,0 +1,73 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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 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.'); + } +} diff --git a/lib/src/commands/hooks.dart b/lib/src/commands/hooks.dart new file mode 100644 index 0000000..4020be9 --- /dev/null +++ b/lib/src/commands/hooks.dart @@ -0,0 +1,36 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/ide.dart b/lib/src/commands/ide.dart new file mode 100644 index 0000000..dfd4efd --- /dev/null +++ b/lib/src/commands/ide.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/init.dart b/lib/src/commands/init.dart new file mode 100644 index 0000000..08043bc --- /dev/null +++ b/lib/src/commands/init.dart @@ -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 run(CommandContext context, List 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 _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> _collectArchitectureNotes(String workingDirectory) async { + final notes = []; + 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> _collectDetectedCommands(String workingDirectory) async { + final commands = []; + 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() + .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> _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 = []; + 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 []; + } +} diff --git a/lib/src/commands/init_verifiers.dart b/lib/src/commands/init_verifiers.dart new file mode 100644 index 0000000..69dfd8b --- /dev/null +++ b/lib/src/commands/init_verifiers.dart @@ -0,0 +1,21 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/install_github_app.dart b/lib/src/commands/install_github_app.dart new file mode 100644 index 0000000..3b52a14 --- /dev/null +++ b/lib/src/commands/install_github_app.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/keybindings.dart b/lib/src/commands/keybindings.dart new file mode 100644 index 0000000..2b15b4e --- /dev/null +++ b/lib/src/commands/keybindings.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import '../command.dart'; +import '../local_state.dart' show joinPath; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/kill.dart b/lib/src/commands/kill.dart new file mode 100644 index 0000000..cda8ade --- /dev/null +++ b/lib/src/commands/kill.dart @@ -0,0 +1,28 @@ +import '../command.dart'; +import '../daemon/daemon_manager.dart'; + +Future run(CommandContext context, List args) async { + if (args.isEmpty) { + context.writeLine("Usage: /kill [--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); +} diff --git a/lib/src/commands/lint.dart b/lib/src/commands/lint.dart new file mode 100644 index 0000000..de55851 --- /dev/null +++ b/lib/src/commands/lint.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import '../command.dart'; +import '../local_state.dart' show joinPath; +import '_shared.dart'; + +Future run(CommandContext context, List 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 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); + } +} diff --git a/lib/src/commands/login.dart b/lib/src/commands/login.dart new file mode 100644 index 0000000..6ebf0fa --- /dev/null +++ b/lib/src/commands/login.dart @@ -0,0 +1,9 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/logout.dart b/lib/src/commands/logout.dart new file mode 100644 index 0000000..f61045e --- /dev/null +++ b/lib/src/commands/logout.dart @@ -0,0 +1,6 @@ +import '../command.dart'; + +Future run(CommandContext context, List args) async { + context.writeLine('To remove your OpenRouter API key, clear it in Settings.'); + return const CommandResult(); +} diff --git a/lib/src/commands/logs.dart b/lib/src/commands/logs.dart new file mode 100644 index 0000000..3780c38 --- /dev/null +++ b/lib/src/commands/logs.dart @@ -0,0 +1,29 @@ +import '../command.dart'; +import '../daemon/daemon_manager.dart'; + +Future run(CommandContext context, List args) async { + if (args.isEmpty) { + context.writeLine("Usage: /logs [--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); +} diff --git a/lib/src/commands/mcp.dart b/lib/src/commands/mcp.dart new file mode 100644 index 0000000..0950cd6 --- /dev/null +++ b/lib/src/commands/mcp.dart @@ -0,0 +1,111 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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 [args...]|remove |enable |disable ]\n\n' + 'Manage MCP (Model Context Protocol) servers.', + ); + return const CommandResult(); + } + + final servers = Map>.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 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 [args...]'); + return const CommandResult(exitCode: 64); + } + + final name = parts[1]; + final command = parts[2]; + final cmdArgs = parts.length > 3 ? parts.sublist(3) : []; + + servers[name] = { + '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 '); + 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.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.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); +} diff --git a/lib/src/commands/memory.dart b/lib/src/commands/memory.dart new file mode 100644 index 0000000..4aaa1c1 --- /dev/null +++ b/lib/src/commands/memory.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import '../command.dart'; +import '../local_state.dart' show joinPath; +import '_shared.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/mobile.dart b/lib/src/commands/mobile.dart new file mode 100644 index 0000000..b854db0 --- /dev/null +++ b/lib/src/commands/mobile.dart @@ -0,0 +1,22 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/model.dart b/lib/src/commands/model.dart new file mode 100644 index 0000000..b7c638c --- /dev/null +++ b/lib/src/commands/model.dart @@ -0,0 +1,66 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List args) async { + final rawArgs = args.join(' ').trim(); + if (commonHelpArgs.contains(rawArgs.toLowerCase())) { + context.writeLine('Usage: /model [default|current|status|]'); + 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'); +} diff --git a/lib/src/commands/output_style.dart b/lib/src/commands/output_style.dart new file mode 100644 index 0000000..5ca5675 --- /dev/null +++ b/lib/src/commands/output_style.dart @@ -0,0 +1,8 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/permissions.dart b/lib/src/commands/permissions.dart new file mode 100644 index 0000000..90b65eb --- /dev/null +++ b/lib/src/commands/permissions.dart @@ -0,0 +1,235 @@ +import '../command.dart'; +import '../local_state.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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 |allow |deny |ask |remove |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 (!['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 '); + 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; +} diff --git a/lib/src/commands/plan.dart b/lib/src/commands/plan.dart new file mode 100644 index 0000000..74237ee --- /dev/null +++ b/lib/src/commands/plan.dart @@ -0,0 +1,36 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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 [] : 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(); +} diff --git a/lib/src/commands/plugin.dart b/lib/src/commands/plugin.dart new file mode 100644 index 0000000..5e1c08a --- /dev/null +++ b/lib/src/commands/plugin.dart @@ -0,0 +1,72 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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 "); + } 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); +} diff --git a/lib/src/commands/pr_comments.dart b/lib/src/commands/pr_comments.dart new file mode 100644 index 0000000..d5a60a6 --- /dev/null +++ b/lib/src/commands/pr_comments.dart @@ -0,0 +1,28 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/privacy_settings.dart b/lib/src/commands/privacy_settings.dart new file mode 100644 index 0000000..6d76984 --- /dev/null +++ b/lib/src/commands/privacy_settings.dart @@ -0,0 +1,27 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/ps.dart b/lib/src/commands/ps.dart new file mode 100644 index 0000000..c3aa92f --- /dev/null +++ b/lib/src/commands/ps.dart @@ -0,0 +1,27 @@ +import '../command.dart'; +import '../daemon/daemon_manager.dart'; +import '../daemon/daemon_types.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/release_notes.dart b/lib/src/commands/release_notes.dart new file mode 100644 index 0000000..a37a1dd --- /dev/null +++ b/lib/src/commands/release_notes.dart @@ -0,0 +1,15 @@ +import '../build_info.dart'; +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/rename.dart b/lib/src/commands/rename.dart new file mode 100644 index 0000000..9622992 --- /dev/null +++ b/lib/src/commands/rename.dart @@ -0,0 +1,26 @@ +import '../command.dart'; +import '../session/session_store.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List args) async { + final newName = args.join(" ").trim(); + + if (newName.isEmpty || commonHelpArgs.contains(newName.toLowerCase())) { + context.writeLine( + 'Usage: /rename \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(); +} diff --git a/lib/src/commands/resume.dart b/lib/src/commands/resume.dart new file mode 100644 index 0000000..274dcca --- /dev/null +++ b/lib/src/commands/resume.dart @@ -0,0 +1,52 @@ +import '../command.dart'; +import '../session/session_store.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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 "); + + 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(); +} diff --git a/lib/src/commands/review.dart b/lib/src/commands/review.dart new file mode 100644 index 0000000..1fbf56d --- /dev/null +++ b/lib/src/commands/review.dart @@ -0,0 +1,25 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/rewind.dart b/lib/src/commands/rewind.dart new file mode 100644 index 0000000..7f92a6b --- /dev/null +++ b/lib/src/commands/rewind.dart @@ -0,0 +1,13 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/security_review.dart b/lib/src/commands/security_review.dart new file mode 100644 index 0000000..4aa2363 --- /dev/null +++ b/lib/src/commands/security_review.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/session.dart b/lib/src/commands/session.dart new file mode 100644 index 0000000..0ad0feb --- /dev/null +++ b/lib/src/commands/session.dart @@ -0,0 +1,11 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/skills.dart b/lib/src/commands/skills.dart new file mode 100644 index 0000000..e392da9 --- /dev/null +++ b/lib/src/commands/skills.dart @@ -0,0 +1,15 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/stats.dart b/lib/src/commands/stats.dart new file mode 100644 index 0000000..0a36811 --- /dev/null +++ b/lib/src/commands/stats.dart @@ -0,0 +1,31 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/status.dart b/lib/src/commands/status.dart new file mode 100644 index 0000000..d2ffac7 --- /dev/null +++ b/lib/src/commands/status.dart @@ -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 run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/statusline.dart b/lib/src/commands/statusline.dart new file mode 100644 index 0000000..f774776 --- /dev/null +++ b/lib/src/commands/statusline.dart @@ -0,0 +1,31 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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|]'); + 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(); +} diff --git a/lib/src/commands/stickers.dart b/lib/src/commands/stickers.dart new file mode 100644 index 0000000..b95da2e --- /dev/null +++ b/lib/src/commands/stickers.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/tag.dart b/lib/src/commands/tag.dart new file mode 100644 index 0000000..1fb1fc8 --- /dev/null +++ b/lib/src/commands/tag.dart @@ -0,0 +1,42 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List args) async { + final tagArg = args.join(" ").trim(); + + if (tagArg.isEmpty || commonHelpArgs.contains(tagArg) || commonInfoArgs.contains(tagArg)) { + context.writeLine( + 'Usage: /tag \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(); +} diff --git a/lib/src/commands/tasks.dart b/lib/src/commands/tasks.dart new file mode 100644 index 0000000..8b92aae --- /dev/null +++ b/lib/src/commands/tasks.dart @@ -0,0 +1,9 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/commands/terminal_setup.dart b/lib/src/commands/terminal_setup.dart new file mode 100644 index 0000000..512b92e --- /dev/null +++ b/lib/src/commands/terminal_setup.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import '../command.dart'; + +const _nativeCsiuTerminals = [ + 'ghostty', 'kitty', 'iTerm.app', 'WezTerm', 'WarpTerminal', +]; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/theme.dart b/lib/src/commands/theme.dart new file mode 100644 index 0000000..3e5583e --- /dev/null +++ b/lib/src/commands/theme.dart @@ -0,0 +1,22 @@ +import '../command.dart'; +import '../local_state.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/tools.dart b/lib/src/commands/tools.dart new file mode 100644 index 0000000..f42c40e --- /dev/null +++ b/lib/src/commands/tools.dart @@ -0,0 +1,18 @@ +import '../command.dart'; +import '../tools/tool_registry.dart'; + +Future run(CommandContext context, List 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: (e.g. bash: echo hello)'); + return const CommandResult(); +} diff --git a/lib/src/commands/upgrade.dart b/lib/src/commands/upgrade.dart new file mode 100644 index 0000000..ade1f9b --- /dev/null +++ b/lib/src/commands/upgrade.dart @@ -0,0 +1,22 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/usage.dart b/lib/src/commands/usage.dart new file mode 100644 index 0000000..21b2151 --- /dev/null +++ b/lib/src/commands/usage.dart @@ -0,0 +1,22 @@ +import '../command.dart'; +import '_shared.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/version.dart b/lib/src/commands/version.dart new file mode 100644 index 0000000..52b8e33 --- /dev/null +++ b/lib/src/commands/version.dart @@ -0,0 +1,7 @@ +import '../build_info.dart'; +import '../command.dart'; + +Future run(CommandContext context, List args) async { + context.writeLine(BuildInfo.versionDisplay); + return const CommandResult(); +} diff --git a/lib/src/commands/vim.dart b/lib/src/commands/vim.dart new file mode 100644 index 0000000..c4cd7cd --- /dev/null +++ b/lib/src/commands/vim.dart @@ -0,0 +1,16 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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(); +} diff --git a/lib/src/commands/voice.dart b/lib/src/commands/voice.dart new file mode 100644 index 0000000..26d2691 --- /dev/null +++ b/lib/src/commands/voice.dart @@ -0,0 +1,11 @@ +import '../command.dart'; + +Future run(CommandContext context, List 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); +} diff --git a/lib/src/local_state.dart b/lib/src/local_state.dart index 52148a4..9508075 100644 --- a/lib/src/local_state.dart +++ b/lib/src/local_state.dart @@ -71,11 +71,12 @@ String joinPath(String base, String child) { class LocalSettings { const LocalSettings({ this.advisorModel, + this.advisorEffortLevel, this.alwaysAllowRules = const [], this.alwaysAskRules = const [], this.alwaysDenyRules = const [], 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 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 alwaysAllowRules; final List alwaysAskRules; final List 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? alwaysAllowRules, List? alwaysAskRules, List? 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?, mcpServers: identical(mcpServers, _sentinel) ? this.mcpServers : mcpServers as Map>?, @@ -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 toJson() { return { 'advisorModel': advisorModel, + 'advisorEffortLevel': advisorEffortLevel, 'alwaysAllowRules': alwaysAllowRules, 'alwaysAskRules': alwaysAskRules, 'alwaysDenyRules': alwaysDenyRules, diff --git a/lib/src/session/conversation_history.dart b/lib/src/session/conversation_history.dart index 8cd5954..051e84e 100644 --- a/lib/src/session/conversation_history.dart +++ b/lib/src/session/conversation_history.dart @@ -21,10 +21,10 @@ class ConversationHistory { _session = s; } - void addMessage(String role, String content, {int? tokens, int? contextTokens, List? attachments}) { + void addMessage(String role, String content, {int? tokens, int? contextTokens, List? 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, ); } diff --git a/lib/src/session/session_runtime.dart b/lib/src/session/session_runtime.dart index baa983e..36bf1af 100644 --- a/lib/src/session/session_runtime.dart +++ b/lib/src/session/session_runtime.dart @@ -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 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 Function(String rule)? _onPersistAllowRule; + + bool _nameGenerated = false; late final ConversationHistory _conversationHistory; late List> _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 resolvePermission(PermissionDecision decision) async { + Future 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> _buildApiMessages(List messages) { - return messages - .where((m) => m.role == "user" || m.role == "assistant") - .map((m) => {"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) => {"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 = >[ + {"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) { diff --git a/lib/src/session/session_types.dart b/lib/src/session/session_types.dart index 540c07c..7b1b7b6 100644 --- a/lib/src/session/session_types.dart +++ b/lib/src/session/session_types.dart @@ -1,5 +1,8 @@ // message roles - same as what the API uses -const validRoles = ["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 = ["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 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? attachments; + // cost in USD for this turn (main model + any advisor calls), null for non-assistant messages + final double? cost; + Map toJson() { return { "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? messages, - this.cost, this.model, this.workingDirectory, List? 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?) @@ -119,8 +126,6 @@ class ConversationSession { DateTime updated; final List messages; - // total cost in USD - optional - double? cost; String? model; String? workingDirectory; List 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? 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, diff --git a/lib/src/skills/skill_registry.dart b/lib/src/skills/skill_registry.dart index b70fe42..71bd705 100644 --- a/lib/src/skills/skill_registry.dart +++ b/lib/src/skills/skill_registry.dart @@ -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.", diff --git a/lib/src/system_prompt/claude_md_loader.dart b/lib/src/system_prompt/claude_md_loader.dart index 417bbc5..08e9f38 100644 --- a/lib/src/system_prompt/claude_md_loader.dart +++ b/lib/src/system_prompt/claude_md_loader.dart @@ -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> 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> 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> 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> 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, diff --git a/lib/src/system_prompt/system_prompt_builder.dart b/lib/src/system_prompt/system_prompt_builder.dart index 1000764..83ee2f5 100644 --- a/lib/src/system_prompt/system_prompt_builder.dart +++ b/lib/src/system_prompt/system_prompt_builder.dart @@ -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 " diff --git a/lib/src/tools/bash_tool.dart b/lib/src/tools/bash_tool.dart index 66e726e..76f4809 100644 --- a/lib/src/tools/bash_tool.dart +++ b/lib/src/tools/bash_tool.dart @@ -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 execute(Map input) async { + return executeStreaming(input, onChunk: (_) {}); + } + + @override + Future executeStreaming( + Map 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 _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); diff --git a/lib/src/tools/execute_task_tool.dart b/lib/src/tools/execute_task_tool.dart index 8b9ddf1..eb9bb9f 100644 --- a/lib/src/tools/execute_task_tool.dart +++ b/lib/src/tools/execute_task_tool.dart @@ -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 execute(Map input) async { + return executeStreaming(input, onChunk: (_) {}); + } + + @override + Future executeStreaming( + Map 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, diff --git a/lib/src/tools/file_read_tool.dart b/lib/src/tools/file_read_tool.dart index aa21f4c..e990ea0 100644 --- a/lib/src/tools/file_read_tool.dart +++ b/lib/src/tools/file_read_tool.dart @@ -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\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" + "\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 _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 execute(Map 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 _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 "Warning: the file exists but the contents are empty."; + } + return "Warning: the file exists but is shorter than the provided offset ($startLine). The file has $totalLines lines."; + } - // 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 _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:: + return "IMAGE_BLOCK:$outputMediaType:$base64Data"; + } + + + // ---- notebook reading ---- + + Future _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 notebook; + try { + notebook = jsonDecode(raw) as Map; + } catch (e) { + return "Error: Failed to parse notebook JSON: $e"; + } + + final meta = notebook["metadata"] as Map? ?? {}; + final langInfo = meta["language_info"] as Map? ?? {}; + final language = langInfo["name"] as String? ?? "python"; + + final rawCells = notebook["cells"] as List? ?? []; + + final buf = StringBuffer(); + for (var i = 0; i < rawCells.length; i++) { + final cell = rawCells[i] as Map; + 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(""); + if (cellType != "code") { + buf.write("$cellType"); + } + if (cellType == "code" && language != "python") { + buf.write("$language"); + } + buf.write(source); + buf.write(""); + + // outputs for code cells + if (cellType == "code") { + final outputs = cell["outputs"] as List? ?? []; + for (final output in outputs) { + final o = output as Map; + 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? ?? {}; + 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? ?? []).join("\n"); + buf.write("\n$ename: $evalue\n$tb"); + break; + } + } + } + + buf.write("\n"); + } + + return buf.toString(); + } + + + // ---- ENOENT helpers ---- + + Future _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 _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//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"; } } diff --git a/lib/src/tools/grep_tool.dart b/lib/src/tools/grep_tool.dart index 6fa2941..549c056 100644 --- a/lib/src/tools/grep_tool.dart +++ b/lib/src/tools/grep_tool.dart @@ -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 = >{ + "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] ?? []) : []; + + // 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 _findRipgrep() async { - for (final candidate in ["rg", "/usr/bin/rg", "/usr/local/bin/rg"]) { + // --- output modes --- + + Future _modeFilesWithMatches( + List 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 _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 _modeCount( + List 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 = []; - final args = ["--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 _runPureDart({ - required String pattern, - String? path, - String? glob, - required String outputMode, - required bool caseInsensitive, + Future _modeContent( + List 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 = []; - final regex = RegExp(pattern, caseSensitive: !caseInsensitive, multiLine: true); + for (final f in files) { + List lines; + try { + lines = await f.readAsLines(); + } catch (_) { + continue; + } - final matchedFiles = []; - final contentLines = []; - 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 = []; + 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 _searchFile({ - required File file, - required RegExp regex, - required String? glob, - required String outputMode, - required bool showLineNumbers, - required String baseDir, - required List matchedFiles, - required List 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 lines, String outputMode, int? headLimit, int offset) { - // apply offset + head_limit - final effectiveLimit = (headLimit == 0) ? null : (headLimit ?? _defaultHeadLimit); - - List 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> _walkDir(Directory dir) async { + final results = []; + + 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 _parseGlob(String? glob) { + if (glob == null || glob.isEmpty) return []; + + final rawPatterns = glob.trim().split(RegExp(r"\s+")); + final out = []; + + 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 _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 = []; + 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 _applyHeadLimit(List 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(); } } diff --git a/lib/src/tools/streaming_tool.dart b/lib/src/tools/streaming_tool.dart new file mode 100644 index 0000000..13fa769 --- /dev/null +++ b/lib/src/tools/streaming_tool.dart @@ -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 executeStreaming( + Map input, { + required void Function(String chunk) onChunk, + }); +} diff --git a/lib/ui/app.dart b/lib/ui/app.dart index 654e21c..095223b 100644 --- a/lib/ui/app.dart +++ b/lib/ui/app.dart @@ -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, ), diff --git a/lib/ui/constants.dart b/lib/ui/constants.dart index 93c07b6..cdca045 100644 --- a/lib/ui/constants.dart +++ b/lib/ui/constants.dart @@ -27,6 +27,11 @@ const List 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 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", + ), ]; diff --git a/lib/ui/pages/home_screen/page.dart b/lib/ui/pages/home_screen/page.dart index 626df1d..053548d 100644 --- a/lib/ui/pages/home_screen/page.dart +++ b/lib/ui/pages/home_screen/page.dart @@ -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( - 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(), ); } } diff --git a/lib/ui/providers/chat_provider.dart b/lib/ui/providers/chat_provider.dart index 3fe2c29..b3882e2 100644 --- a/lib/ui/providers/chat_provider.dart +++ b/lib/ui/providers/chat_provider.dart @@ -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 _runtimes = {}; + final Map _sessions = {}; String? _activeSessionId; + bool _notifyScheduled = false; + + void _scheduleNotify() { + if (_notifyScheduled) return; + _notifyScheduled = true; + + SchedulerBinding.instance.scheduleFrameCallback((_) { + _notifyScheduled = false; + notifyListeners(); + _costProvider.refreshCost(); + }); + } + // ─── hooks ────────────────────────────────────────────────────────────────── Future _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 resolvePermission(PermissionDecision decision) => - _active?.resolvePermission(decision) ?? Future.value(); + Future resolvePermission(PermissionDecision decision, {String? persistRule}) => + _active?.resolvePermission(decision, persistRule: persistRule) ?? Future.value(); void removeQueuedMessage(int index) => _active?.removeQueuedMessage(index); diff --git a/lib/ui/providers/cost_provider.dart b/lib/ui/providers/cost_provider.dart index 7dd9010..bcd340e 100644 --- a/lib/ui/providers/cost_provider.dart +++ b/lib/ui/providers/cost_provider.dart @@ -12,8 +12,6 @@ class CostProvider extends ChangeNotifier { String getFormattedTotalCost() => cost_tracker.formatTotalCost(); Future refreshCost() async { - // read current values from cost tracker - final _ = cost_tracker.getTotalCostUsd(); notifyListeners(); } } diff --git a/lib/ui/providers/home_coordinator.dart b/lib/ui/providers/home_coordinator.dart index 83dc030..dc0b9db 100644 --- a/lib/ui/providers/home_coordinator.dart +++ b/lib/ui/providers/home_coordinator.dart @@ -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); } } diff --git a/lib/ui/providers/settings_provider.dart b/lib/ui/providers/settings_provider.dart index b6c2946..c6a0157 100644 --- a/lib/ui/providers/settings_provider.dart +++ b/lib/ui/providers/settings_provider.dart @@ -82,6 +82,22 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future updateAdvisorEffortLevel(String? level) async { + await _settingsStore.update( + (current) => current.copyWith(advisorEffortLevel: level), + ); + _globalSettings = _settingsStore.settings; + notifyListeners(); + } + + Future updateAdvisorModel(String? model) async { + await _settingsStore.update( + (current) => current.copyWith(advisorModel: model), + ); + _globalSettings = _settingsStore.settings; + notifyListeners(); + } + Future updateEffortLevel(String newLevel) async { await _settingsStore.update( (current) => current.copyWith(effortLevel: newLevel), diff --git a/lib/ui/widgets/chat/bubbles/assistant_bubble.dart b/lib/ui/widgets/chat/bubbles/assistant_bubble.dart index 458229a..71cb250 100644 --- a/lib/ui/widgets/chat/bubbles/assistant_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/assistant_bubble.dart @@ -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); } } diff --git a/lib/ui/widgets/chat/bubbles/permission_decision.dart b/lib/ui/widgets/chat/bubbles/permission_decision.dart index 7b1558d..691d8f0 100644 --- a/lib/ui/widgets/chat/bubbles/permission_decision.dart +++ b/lib/ui/widgets/chat/bubbles/permission_decision.dart @@ -1 +1 @@ -export "../../../../src/permissions/permission_types.dart" show PermissionDecision; +export "../../../../src/permissions/permission_types.dart" show PermissionDecision, PendingPermission; diff --git a/lib/ui/widgets/chat/bubbles/tool_bubble.dart b/lib/ui/widgets/chat/bubbles/tool_bubble.dart index 6c59ce4..cee2c31 100644 --- a/lib/ui/widgets/chat/bubbles/tool_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tool_bubble.dart @@ -1,5 +1,6 @@ import "dart:convert"; import "package:shadcn_flutter/shadcn_flutter.dart"; +import "../../../../src/permissions/permission_types.dart"; import "tools/advisor_bubble.dart"; import "tools/bash_bubble.dart"; import "tools/default_tool_bubble.dart"; @@ -17,13 +18,13 @@ class ToolBubble extends StatelessWidget { required this.toolName, this.toolInput, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final String toolName; final Map? toolInput; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; // parse a tool message content string into (toolName, toolInput) // format: "$toolName call\n{json}" or "$toolName result\n..." @@ -41,7 +42,11 @@ class ToolBubble extends StatelessWidget { if (firstLine.endsWith(" call") && rest.isNotEmpty) { try { - final decoded = jsonDecode(rest); + // streamed output may be appended after the json block, so only + // decode up to the closing brace — not the whole rest string + final jsonEnd = _findJsonEnd(rest); + final jsonStr = jsonEnd != -1 ? rest.substring(0, jsonEnd + 1) : rest; + final decoded = jsonDecode(jsonStr); if (decoded is Map) { return (name, decoded); } @@ -51,6 +56,27 @@ class ToolBubble extends StatelessWidget { return (name, null); } + // find the index of the closing } that ends the top-level json object + static int _findJsonEnd(String s) { + int depth = 0; + bool inString = false; + for (int i = 0; i < s.length; i++) { + final c = s[i]; + if (inString) { + if (c == "\\" ) { i++; continue; } // skip escaped char + if (c == "\"") inString = false; + } else { + if (c == "\"") inString = true; + else if (c == "{") depth++; + else if (c == "}") { + depth--; + if (depth == 0) return i; + } + } + } + return -1; + } + static String _extractName(String line) { // strip trailing " call" or " result" if (line.endsWith(" call")) return line.substring(0, line.length - 5).trim(); @@ -64,26 +90,26 @@ class ToolBubble extends StatelessWidget { switch (toolName) { case "Bash": - return BashBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return BashBubble(input: input, result: result, pendingPermission: pendingPermission); case "Edit": - return EditBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return EditBubble(input: input, result: result, pendingPermission: pendingPermission); case "Read": - return ReadBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return ReadBubble(input: input, result: result, pendingPermission: pendingPermission); case "Write": - return WriteBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return WriteBubble(input: input, result: result, pendingPermission: pendingPermission); case "Glob": - return GlobBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return GlobBubble(input: input, result: result, pendingPermission: pendingPermission); case "Grep": - return GrepBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return GrepBubble(input: input, result: result, pendingPermission: pendingPermission); case "WebSearch": - return WebSearchBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return WebSearchBubble(input: input, result: result, pendingPermission: pendingPermission); case "WebFetch": - return WebFetchBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return WebFetchBubble(input: input, result: result, pendingPermission: pendingPermission); case "Advisor": - return AdvisorBubble(input: input, result: result, isPendingPermission: isPendingPermission); + return AdvisorBubble(input: input, result: result, pendingPermission: pendingPermission); default: - return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, isPendingPermission: isPendingPermission); + return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, pendingPermission: pendingPermission); } } } diff --git a/lib/ui/widgets/chat/bubbles/tools/advisor_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/advisor_bubble.dart index 5384884..e77c3b9 100644 --- a/lib/ui/widgets/chat/bubbles/tools/advisor_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/advisor_bubble.dart @@ -1,4 +1,5 @@ import "package:shadcn_flutter/shadcn_flutter.dart"; +import "../permission_decision.dart"; import "tool_bubble_base.dart"; class AdvisorBubble extends StatelessWidget { @@ -6,12 +7,12 @@ class AdvisorBubble extends StatelessWidget { super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -21,7 +22,7 @@ class AdvisorBubble extends StatelessWidget { toolName: "Advisor", icon: LucideIcons.brain, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, detail: model, ); } diff --git a/lib/ui/widgets/chat/bubbles/tools/bash_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/bash_bubble.dart index b4b49c1..c78b349 100644 --- a/lib/ui/widgets/chat/bubbles/tools/bash_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/bash_bubble.dart @@ -1,28 +1,397 @@ +import "dart:io"; import "package:shadcn_flutter/shadcn_flutter.dart"; -import "tool_bubble_base.dart"; +import "package:provider/provider.dart"; +import "../../../../providers/chat_provider.dart"; +import "../permission_decision.dart"; +import "../../../common/pane_dialog.dart"; -class BashBubble extends StatelessWidget { +class BashBubble extends StatefulWidget { const BashBubble({ super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; + + @override + State createState() => _BashBubbleState(); +} + +class _BashBubbleState extends State { + + final _scrollController = ScrollController(); + late final TextEditingController _ruleController; + bool _overflows = false; + + @override + void initState() { + super.initState(); + _ruleController = TextEditingController( + text: widget.pendingPermission?.suggestionRule ?? "Bash", + ); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkOverflow()); + } + + @override + void didUpdateWidget(BashBubble oldWidget) { + super.didUpdateWidget(oldWidget); + final newRule = widget.pendingPermission?.suggestionRule ?? "Bash"; + final oldRule = oldWidget.pendingPermission?.suggestionRule ?? "Bash"; + if (newRule != oldRule) _ruleController.text = newRule; + if (oldWidget.result != widget.result) { + WidgetsBinding.instance.addPostFrameCallback((_) => _checkOverflow()); + } + } + + @override + void dispose() { + _scrollController.dispose(); + _ruleController.dispose(); + super.dispose(); + } + + void _openFullscreen( + BuildContext context, + String command, + Color termBg, + Color termTitleBg, + Color termBorder, + Color outputGray, + ) { + final theme = Theme.of(context); + showDialog( + context: context, + builder: (ctx) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: PaneDialog( + title: "Terminal", + fillHeight: true, + onClose: () => Navigator.of(ctx).pop(), + child: Flexible( + child: Container( + color: termBg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 6), + child: _TerminalPrompt( + command: command, + cwd: widget.input["cwd"] as String? ?? + context.read().workingDirectory, + ), + ), + + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 16), + child: SelectableText( + widget.result ?? "", + style: theme.typography.mono.copyWith( + color: outputGray, + fontSize: 12, + height: 1.4, + ), + ), + ), + ), + + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + void _checkOverflow() { + if (!_scrollController.hasClients) return; + final overflows = _scrollController.position.maxScrollExtent > 0; + if (overflows != _overflows) setState(() => _overflows = overflows); + } @override Widget build(BuildContext context) { - final command = input["command"] as String? ?? ""; + final command = widget.input["command"] as String? ?? ""; + final hasResult = widget.result != null && widget.result!.isNotEmpty; - return ToolBubbleBase( - toolName: "Bash", - icon: LucideIcons.terminal, - result: result, - isPendingPermission: isPendingPermission, - detail: command, + final theme = Theme.of(context); + final dark = theme.brightness == Brightness.dark; + final h = HSLColor.fromColor(theme.colorScheme.border).hue; + final s = HSLColor.fromColor(theme.colorScheme.border).saturation; + + final termBg = dark + ? HSLColor.fromAHSL(1, h, s.clamp(0, 0.35), 0.10).toColor() + : HSLColor.fromAHSL(1, h, s.clamp(0, 0.30), 0.92).toColor(); + + final termTitleBg = dark + ? HSLColor.fromAHSL(1, h, s.clamp(0, 0.35), 0.14).toColor() + : HSLColor.fromAHSL(1, h, s.clamp(0, 0.30), 0.87).toColor(); + + final termBorder = theme.colorScheme.border; + final outputGray = theme.colorScheme.mutedForeground; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Container( + decoration: BoxDecoration( + color: termBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: termBorder, width: 1), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + + // title bar + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: termTitleBg, + border: Border( + bottom: BorderSide(color: termBorder, width: 1), + ), + ), + child: Row( + children: [ + Text( + "Terminal", + style: theme.typography.p.copyWith( + color: theme.colorScheme.mutedForeground, + fontFamily: "monospace", + fontSize: 11, + ), + ), + + const Spacer(), + + if (hasResult) + GestureDetector( + onTap: () => _openFullscreen(context, command, termBg, termTitleBg, termBorder, outputGray), + child: Icon( + LucideIcons.maximize2, + size: 13, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + + + // prompt always visible + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 6), + child: _TerminalPrompt( + command: command, + cwd: widget.input["cwd"] as String? ?? + context.read().workingDirectory, + ), + ), + + // output — shrinks to content, caps at maxHeight, gradient only when overflowing + if (hasResult) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: ShaderMask( + shaderCallback: (rect) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + _overflows ? Colors.transparent : Colors.white, + Colors.white, + ], + stops: const [0.0, 0.15], + ).createShader(rect), + blendMode: BlendMode.dstIn, + child: SingleChildScrollView( + controller: _scrollController, + reverse: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: SelectableText( + widget.result!, + style: theme.typography.mono.copyWith( + color: outputGray, + fontSize: 11, + height: 1.25, + ), + ), + ), + ), + ), + + if (!hasResult) const SizedBox(height: 10), + + ], + ), + ), + ), + + + if (widget.pendingPermission != null) ...[ + + const SizedBox(height: 8), + + Row( + children: [ + Text("Don't ask again for:").xSmall, + const Gap(8), + Expanded( + child: TextField( + controller: _ruleController, + style: const TextStyle(fontSize: 12, fontFamily: "monospace"), + ), + ), + ], + ), + + const SizedBox(height: 6), + + Row( + children: [ + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.check).iconSmall, + onPressed: () => context.read().resolvePermission(PermissionDecision.allowOnce), + child: Text("Yes").small, + ), + ), + + const SizedBox(width: 8), + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.checkCheck).iconSmall, + onPressed: () => context.read().resolvePermission( + PermissionDecision.allowAlways, + persistRule: _ruleController.text.trim(), + ), + child: Text("Yes, don't ask again").small, + ), + ), + + const SizedBox(width: 8), + + Expanded( + child: Button.destructive( + leading: Icon(LucideIcons.x).iconSmall, + onPressed: () => context.read().resolvePermission(PermissionDecision.reject), + child: Text("No").small, + ), + ), + + ], + ), + + ], + + ], + ); + } +} + + +class _TerminalPrompt extends StatelessWidget { + const _TerminalPrompt({required this.command, this.cwd}); + final String command; + final String? cwd; + + String _shortCwd() { + try { + final home = Platform.environment["HOME"] ?? ""; + var resolved = cwd ?? Directory.current.path; + if (home.isNotEmpty && resolved.startsWith(home)) { + resolved = "~${resolved.substring(home.length)}"; + } + return resolved; + } catch (_) { + return "~"; + } + } + + String _hostname() { + try { + return Platform.localHostname.split(".").first; + } catch (_) { + return "localhost"; + } + } + + @override + Widget build(BuildContext context) { + final hostname = _hostname(); + final cwd = _shortCwd(); + final theme = Theme.of(context); + final monoBase = theme.typography.mono.copyWith( + fontSize: 11, + height: 1.25, + ); + + final fg = theme.colorScheme.foreground; + + return RichText( + text: TextSpan( + style: monoBase, + children: [ + + // the_agency@hostname — green + TextSpan( + text: "the_agency@$hostname", + style: const TextStyle(color: Color(0xFF4EC94E), fontWeight: FontWeight.w600), + ), + + TextSpan(text: ":", style: TextStyle(color: fg)), + + // cwd — blue + TextSpan( + text: cwd, + style: const TextStyle(color: Color(0xFF5BB8FF)), + ), + + TextSpan(text: "\$ ", style: TextStyle(color: fg)), + + TextSpan(text: command, style: TextStyle(color: fg)), + + ], + ), + ); + } +} + + +class _Dot extends StatelessWidget { + const _Dot({required this.color}); + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), ); } } diff --git a/lib/ui/widgets/chat/bubbles/tools/default_tool_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/default_tool_bubble.dart index 91f144a..8488504 100644 --- a/lib/ui/widgets/chat/bubbles/tools/default_tool_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/default_tool_bubble.dart @@ -1,5 +1,6 @@ import "dart:convert"; import "package:shadcn_flutter/shadcn_flutter.dart"; +import "../permission_decision.dart"; import "tool_bubble_base.dart"; class DefaultToolBubble extends StatelessWidget { @@ -8,13 +9,13 @@ class DefaultToolBubble extends StatelessWidget { required this.toolName, this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final String toolName; final Map? input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -24,7 +25,7 @@ class DefaultToolBubble extends StatelessWidget { toolName: toolName, icon: LucideIcons.wrench, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, body: input != null && input!.isNotEmpty ? Padding( padding: const EdgeInsets.only(left: 4), diff --git a/lib/ui/widgets/chat/bubbles/tools/edit_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/edit_bubble.dart index 6f7198d..05e8a44 100644 --- a/lib/ui/widgets/chat/bubbles/tools/edit_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/edit_bubble.dart @@ -1,6 +1,7 @@ import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../../providers/chat_provider.dart"; +import "../permission_decision.dart"; import "../../../../utils/path_utils.dart"; import "../../diff_view.dart"; import "tool_bubble_base.dart"; @@ -10,12 +11,12 @@ class EditBubble extends StatelessWidget { super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -28,7 +29,7 @@ class EditBubble extends StatelessWidget { toolName: "Edit", icon: LucideIcons.filePen, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, detail: shortenPath(filePath, projectRoot), body: DiffView( oldString: oldString, diff --git a/lib/ui/widgets/chat/bubbles/tools/glob_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/glob_bubble.dart index 3f152c4..d0c57e9 100644 --- a/lib/ui/widgets/chat/bubbles/tools/glob_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/glob_bubble.dart @@ -1,6 +1,7 @@ import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../../providers/chat_provider.dart"; +import "../permission_decision.dart"; import "../../../../utils/path_utils.dart"; import "tool_bubble_base.dart"; @@ -9,12 +10,12 @@ class GlobBubble extends StatelessWidget { super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -30,7 +31,7 @@ class GlobBubble extends StatelessWidget { toolName: "Glob", icon: LucideIcons.folderSearch, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, detail: detail, ); } diff --git a/lib/ui/widgets/chat/bubbles/tools/grep_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/grep_bubble.dart index 1b7f16e..3d7beae 100644 --- a/lib/ui/widgets/chat/bubbles/tools/grep_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/grep_bubble.dart @@ -1,6 +1,7 @@ import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../../providers/chat_provider.dart"; +import "../permission_decision.dart"; import "../../../../utils/path_utils.dart"; import "tool_bubble_base.dart"; @@ -9,12 +10,12 @@ class GrepBubble extends StatelessWidget { super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -30,7 +31,7 @@ class GrepBubble extends StatelessWidget { toolName: "Grep", icon: LucideIcons.search, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, detail: detail, ); } diff --git a/lib/ui/widgets/chat/bubbles/tools/read_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/read_bubble.dart index acec92b..41b7027 100644 --- a/lib/ui/widgets/chat/bubbles/tools/read_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/read_bubble.dart @@ -1,6 +1,7 @@ import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../../providers/chat_provider.dart"; +import "../permission_decision.dart"; import "../../../../utils/path_utils.dart"; import "tool_bubble_base.dart"; @@ -9,12 +10,12 @@ class ReadBubble extends StatelessWidget { super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -25,7 +26,7 @@ class ReadBubble extends StatelessWidget { toolName: "Read", icon: LucideIcons.fileText, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, detail: shortenPath(filePath, projectRoot), ); } diff --git a/lib/ui/widgets/chat/bubbles/tools/tool_bubble_base.dart b/lib/ui/widgets/chat/bubbles/tools/tool_bubble_base.dart index 73a1937..6226a8e 100644 --- a/lib/ui/widgets/chat/bubbles/tools/tool_bubble_base.dart +++ b/lib/ui/widgets/chat/bubbles/tools/tool_bubble_base.dart @@ -3,7 +3,7 @@ import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../../providers/chat_provider.dart"; import "../permission_decision.dart"; -class ToolBubbleBase extends StatelessWidget { +class ToolBubbleBase extends StatefulWidget { const ToolBubbleBase({ super.key, required this.toolName, @@ -11,7 +11,7 @@ class ToolBubbleBase extends StatelessWidget { this.detail, this.body, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final String toolName; @@ -19,17 +19,77 @@ class ToolBubbleBase extends StatelessWidget { final String? detail; final Widget? body; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; + + @override + State createState() => _ToolBubbleBaseState(); +} + +class _ToolBubbleBaseState extends State { + + late final TextEditingController _ruleController; + + @override + void initState() { + super.initState(); + _ruleController = TextEditingController( + text: widget.pendingPermission?.suggestionRule ?? widget.toolName, + ); + } + + @override + void didUpdateWidget(ToolBubbleBase old) { + super.didUpdateWidget(old); + final newRule = widget.pendingPermission?.suggestionRule ?? widget.toolName; + final oldRule = old.pendingPermission?.suggestionRule ?? old.toolName; + if (newRule != oldRule) { + _ruleController.text = newRule; + } + } + + @override + void dispose() { + _ruleController.dispose(); + super.dispose(); + } + + bool get _isFileTool { + const ft = {"Edit", "Write", "Read", "MultiEdit"}; + return ft.contains(widget.toolName); + } + + bool get _isBash => widget.toolName == "Bash"; + + bool get _isWebFetch => widget.toolName == "WebFetch" || widget.toolName == "WebSearch"; + + // extract hostname from a url for the WebFetch "dont ask again" label + String _hostnameFromPending() { + final pp = widget.pendingPermission; + if (pp == null) return ""; + final url = pp.input["url"] as String? ?? pp.input["query"] as String? ?? ""; + try { + return Uri.parse(url).host; + } catch (_) { + return url; + } + } + + // rule to persist for webfetch — "WebFetch(hostname:*)" + String _webFetchPersistRule() { + final host = _hostnameFromPending(); + if (host.isEmpty) return widget.toolName; + return "WebFetch($host:*)"; + } @override Widget build(BuildContext context) { final theme = Theme.of(context); + final pp = widget.pendingPermission; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - OutlinedContainer( clipBehavior: Clip.antiAlias, child: Column( @@ -47,24 +107,17 @@ class ToolBubbleBase extends StatelessWidget { ), child: Row( children: [ - Icon(icon).iconSmall, - + Icon(widget.icon).iconSmall, Gap(8), - - Text( - toolName, - ).textSmall, - + Text(widget.toolName).textSmall, ], ), ), VerticalDivider(), - if (detail != null)...[ + if (widget.detail != null)...[ Gap(16), - Text( - detail!, - ).mono.xSmall + Text(widget.detail!).mono.xSmall ] ], @@ -72,14 +125,12 @@ class ToolBubbleBase extends StatelessWidget { ), - - if (body != null) ...[ + if (widget.body != null) ...[ Divider(), - - body!, + widget.body!, ], - if (result != null) ...[ + if (widget.result != null) ...[ Divider(), Padding( @@ -88,7 +139,7 @@ class ToolBubbleBase extends StatelessWidget { vertical: 8, ), child: SelectableText( - "\u200B${result!}", + "\u200B${widget.result!}", style: TextStyle( color: theme.colorScheme.mutedForeground, ), @@ -96,50 +147,253 @@ class ToolBubbleBase extends StatelessWidget { ) ] - ], ), ), - if (isPendingPermission) ...[ + + if (pp != null) ...[ Gap(8), - Row( - children: [ - Expanded( - child: Button.outline( - leading: Icon(LucideIcons.check).iconSmall, - onPressed: () => context.read().resolvePermission(PermissionDecision.allowOnce), - child: Text("Yes").small, - ), - ), + if (_isBash) ...[ + // bash: editable rule field + buttons + _BashPermissionButtons(ruleController: _ruleController), + ] else if (_isWebFetch) ...[ + // webfetch: domain-scoped dont-ask-again + _WebFetchPermissionButtons( + hostname: _hostnameFromPending(), + persistRule: _webFetchPersistRule(), + ), + ] else if (_isFileTool) ...[ + // file tools: session-scoped only, no persist + _FilePermissionButtons(), + ] else ...[ + // default: show rule in button + _DefaultPermissionButtons( + persistRule: pp.suggestionRule ?? widget.toolName, + ), + ], - Gap(8), - - Expanded( - child: Button.outline( - leading: Icon(LucideIcons.checkCheck).iconSmall, - onPressed: () => context.read().resolvePermission(PermissionDecision.allowAlways), - child: Text("Yes, for this session").small, - ), - ), - - Gap(8), - - Expanded( - child: Button.destructive( - leading: Icon(LucideIcons.x).iconSmall, - onPressed: () => context.read().resolvePermission(PermissionDecision.reject), - child: Text("No").small, - ), - ), - - ], - ), ], ], ); } } + + +// ─── permission button sets ────────────────────────────────────────────────── + +class _BashPermissionButtons extends StatelessWidget { + const _BashPermissionButtons({required this.ruleController}); + final TextEditingController ruleController; + + @override + Widget build(BuildContext context) { + final chat = context.read(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + + Row( + children: [ + Text("Don't ask again for:").xSmall, + Gap(8), + Expanded( + child: TextField( + controller: ruleController, + style: const TextStyle(fontSize: 12, fontFamily: "monospace"), + ), + ), + ], + ), + + Gap(6), + + Row( + children: [ + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.check).iconSmall, + onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce), + child: Text("Yes").small, + ), + ), + + Gap(8), + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.checkCheck).iconSmall, + onPressed: () => chat.resolvePermission( + PermissionDecision.allowAlways, + persistRule: ruleController.text.trim(), + ), + child: Text("Yes, don't ask again").small, + ), + ), + + Gap(8), + + Expanded( + child: Button.destructive( + leading: Icon(LucideIcons.x).iconSmall, + onPressed: () => chat.resolvePermission(PermissionDecision.reject), + child: Text("No").small, + ), + ), + + ], + ), + + ], + ); + } +} + + +class _WebFetchPermissionButtons extends StatelessWidget { + const _WebFetchPermissionButtons({required this.hostname, required this.persistRule}); + final String hostname; + final String persistRule; + + @override + Widget build(BuildContext context) { + final chat = context.read(); + + return Row( + children: [ + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.check).iconSmall, + onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce), + child: Text("Yes").small, + ), + ), + + Gap(8), + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.checkCheck).iconSmall, + onPressed: () => chat.resolvePermission( + PermissionDecision.allowAlways, + persistRule: persistRule, + ), + child: hostname.isNotEmpty + ? Text("Yes, always for $hostname").small + : Text("Yes, don't ask again").small, + ), + ), + + Gap(8), + + Expanded( + child: Button.destructive( + leading: Icon(LucideIcons.x).iconSmall, + onPressed: () => chat.resolvePermission(PermissionDecision.reject), + child: Text("No").small, + ), + ), + + ], + ); + } +} + + +class _FilePermissionButtons extends StatelessWidget { + const _FilePermissionButtons(); + + @override + Widget build(BuildContext context) { + final chat = context.read(); + + return Row( + children: [ + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.check).iconSmall, + onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce), + child: Text("Yes").small, + ), + ), + + Gap(8), + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.checkCheck).iconSmall, + // no persistRule — session-scoped only + onPressed: () => chat.resolvePermission(PermissionDecision.allowAlways), + child: Text("Yes, for this session").small, + ), + ), + + Gap(8), + + Expanded( + child: Button.destructive( + leading: Icon(LucideIcons.x).iconSmall, + onPressed: () => chat.resolvePermission(PermissionDecision.reject), + child: Text("No").small, + ), + ), + + ], + ); + } +} + + +class _DefaultPermissionButtons extends StatelessWidget { + const _DefaultPermissionButtons({required this.persistRule}); + final String persistRule; + + @override + Widget build(BuildContext context) { + final chat = context.read(); + + return Row( + children: [ + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.check).iconSmall, + onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce), + child: Text("Yes").small, + ), + ), + + Gap(8), + + Expanded( + child: Button.outline( + leading: Icon(LucideIcons.checkCheck).iconSmall, + onPressed: () => chat.resolvePermission( + PermissionDecision.allowAlways, + persistRule: persistRule, + ), + child: Text("Yes, don't ask again").small, + ), + ), + + Gap(8), + + Expanded( + child: Button.destructive( + leading: Icon(LucideIcons.x).iconSmall, + onPressed: () => chat.resolvePermission(PermissionDecision.reject), + child: Text("No").small, + ), + ), + + ], + ); + } +} diff --git a/lib/ui/widgets/chat/bubbles/tools/web_fetch_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/web_fetch_bubble.dart index ac0f0d8..7c00267 100644 --- a/lib/ui/widgets/chat/bubbles/tools/web_fetch_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/web_fetch_bubble.dart @@ -1,4 +1,5 @@ import "package:shadcn_flutter/shadcn_flutter.dart"; +import "../permission_decision.dart"; import "tool_bubble_base.dart"; class WebFetchBubble extends StatelessWidget { @@ -6,12 +7,12 @@ class WebFetchBubble extends StatelessWidget { super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -21,7 +22,7 @@ class WebFetchBubble extends StatelessWidget { toolName: "WebFetch", icon: LucideIcons.link, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, detail: url, ); } diff --git a/lib/ui/widgets/chat/bubbles/tools/web_search_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/web_search_bubble.dart index aa23d5a..5c93b60 100644 --- a/lib/ui/widgets/chat/bubbles/tools/web_search_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/web_search_bubble.dart @@ -1,4 +1,5 @@ import "package:shadcn_flutter/shadcn_flutter.dart"; +import "../permission_decision.dart"; import "tool_bubble_base.dart"; class WebSearchBubble extends StatelessWidget { @@ -6,12 +7,12 @@ class WebSearchBubble extends StatelessWidget { super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -21,7 +22,7 @@ class WebSearchBubble extends StatelessWidget { toolName: "WebSearch", icon: LucideIcons.globe, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, detail: query, ); } diff --git a/lib/ui/widgets/chat/bubbles/tools/write_bubble.dart b/lib/ui/widgets/chat/bubbles/tools/write_bubble.dart index 14a23b9..2a9810a 100644 --- a/lib/ui/widgets/chat/bubbles/tools/write_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/tools/write_bubble.dart @@ -1,6 +1,7 @@ import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../../providers/chat_provider.dart"; +import "../permission_decision.dart"; import "../../../../utils/path_utils.dart"; import "../../diff_view.dart"; import "tool_bubble_base.dart"; @@ -10,12 +11,12 @@ class WriteBubble extends StatelessWidget { super.key, required this.input, this.result, - this.isPendingPermission = false, + this.pendingPermission, }); final Map input; final String? result; - final bool isPendingPermission; + final PendingPermission? pendingPermission; @override Widget build(BuildContext context) { @@ -27,7 +28,7 @@ class WriteBubble extends StatelessWidget { toolName: "Write", icon: LucideIcons.filePlus, result: result, - isPendingPermission: isPendingPermission, + pendingPermission: pendingPermission, detail: shortenPath(filePath, projectRoot), body: DiffView( oldString: "", diff --git a/lib/ui/widgets/chat/bubbles/user_bubble.dart b/lib/ui/widgets/chat/bubbles/user_bubble.dart index faa48f8..09de6bd 100644 --- a/lib/ui/widgets/chat/bubbles/user_bubble.dart +++ b/lib/ui/widgets/chat/bubbles/user_bubble.dart @@ -1,24 +1,68 @@ import "dart:typed_data"; +import "package:clawd_code/ui/widgets/common/button.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; +import "package:flutter/services.dart"; import "../../../models/attachment.dart"; import "../attachment_preview.dart"; import "../../../../src/session/session_types.dart"; -class UserBubble extends StatelessWidget { +Color _msgColour(BuildContext context) { + final theme = Theme.of(context); + final dark = theme.brightness == Brightness.dark; + final h = HSLColor.fromColor(theme.colorScheme.border).hue; + return dark + ? HSLColor.fromAHSL(1, h, 0.25, 0.25).toColor() + : HSLColor.fromAHSL(1, h, 0.30, 0.6).toColor(); +} + +class UserBubble extends StatefulWidget { const UserBubble({ super.key, required this.content, this.attachments, + this.timestamp, + this.onRetry, + this.onEdit, }); final String content; final List? attachments; + final DateTime? timestamp; + final VoidCallback? onRetry; + final VoidCallback? onEdit; + + @override + State createState() => _UserBubbleState(); +} + +class _UserBubbleState extends State { + bool _hovered = false; + + String _formatTime(DateTime dt) { + final local = dt.toLocal(); + final now = DateTime.now(); + final h = local.hour.toString().padLeft(2, "0"); + final m = local.minute.toString().padLeft(2, "0"); + final time = "$h:$m"; + + final isToday = local.year == now.year && local.month == now.month && local.day == now.day; + if (isToday) return "Today at $time"; + + // show date for anything older + final day = local.day.toString().padLeft(2, "0"); + final month = local.month.toString().padLeft(2, "0"); + return "${local.year == now.year ? "" : "${local.year}/"}$month/$day at $time"; + } @override Widget build(BuildContext context) { - final atts = attachments; + final atts = widget.attachments; + final muted = Theme.of(context).colorScheme.mutedForeground; - return Align( + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Align( alignment: Alignment.centerRight, child: Column( crossAxisAlignment: CrossAxisAlignment.end, @@ -49,13 +93,97 @@ class UserBubble extends StatelessWidget { ], OutlinedContainer( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - backgroundColor: Theme.of(context).colorScheme.border, - child: SelectableText(content), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + borderColor: _msgColour(context).scaleAlpha(0.9), + backgroundColor: _msgColour(context), + boxShadow: [], + child: SelectableText(widget.content), + ), + + const Gap(6), + + AnimatedOpacity( + opacity: _hovered ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + + if (widget.timestamp != null) + Text( + _formatTime(widget.timestamp!), + style: TextStyle(color: muted), + ).xSmall, + + const Gap(10), + + _ActionBtn( + icon: LucideIcons.refreshCw, + tooltip: "Retry", + onTap: widget.onRetry, + color: muted, + ), + _ActionBtn( + icon: LucideIcons.pencil, + tooltip: "Edit", + onTap: widget.onEdit, + color: muted, + ), + _ActionBtn( + icon: LucideIcons.copy, + tooltip: "Copy", + color: muted, + onTap: () { + Clipboard.setData(ClipboardData(text: widget.content)); + }, + ), + ], + ), ), ], ), + )); + } +} + + +class _ActionBtn extends StatelessWidget { + const _ActionBtn({ + required this.icon, + required this.tooltip, + required this.color, + this.onTap, + }); + + final IconData icon; + final String tooltip; + final Color color; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + + if (true) { + return IconButton.ghost( + onPressed: () { + if (onTap != null) onTap!(); + }, + icon: Icon( + icon, + ).iconSmall.iconMutedForeground, + ); + } + + return Tooltip( + tooltip: TooltipContainer(child: Text(tooltip)), + child: GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), + child: Icon(icon, size: 14, color: color), + ), + ), ); } } diff --git a/lib/ui/widgets/chat/chat_box.dart b/lib/ui/widgets/chat/chat_box.dart index 19a0aa2..c1f1466 100644 --- a/lib/ui/widgets/chat/chat_box.dart +++ b/lib/ui/widgets/chat/chat_box.dart @@ -1,18 +1,22 @@ import "package:shadcn_flutter/shadcn_flutter.dart"; import 'package:pasteboard/pasteboard.dart'; import 'package:flutter/services.dart'; +import 'dart:ui'; import 'package:file_picker/file_picker.dart'; import 'package:provider/provider.dart'; import 'dart:io'; -import '../../constants.dart'; import '../../models/attachment.dart'; import '../../providers/chat_provider.dart'; import '../../providers/home_coordinator.dart'; +import '../../providers/projects_provider.dart'; import '../../providers/session_provider.dart'; +import '../../../src/project_store.dart'; import '../../providers/settings_provider.dart'; import 'attachment_preview.dart'; import '../common/button.dart'; -import 'model_picker_dialog.dart'; +import '../../constants.dart'; +import 'models_panel.dart'; +import '../common/pane_dialog.dart'; class ChatBox extends StatefulWidget { const ChatBox({super.key}); @@ -178,6 +182,49 @@ class _ChatBoxState extends State { } } + void _openModelDialog(BuildContext context) { + final bgColor = Theme.of(context).colorScheme.background; + + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: "models", + barrierColor: Colors.transparent, + pageBuilder: (ctx, animation, _) { + return Stack( + children: [ + // blurred tinted backdrop + Positioned.fill( + child: FadeTransition( + opacity: animation, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), + child: GestureDetector( + onTap: () => Navigator.of(ctx).pop(), + child: ColoredBox( + color: bgColor.withValues(alpha: 0.35), + ), + ), + ), + ), + ), + + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 420, maxWidth: 420), + child: PaneDialog( + title: "Models", + onClose: () => Navigator.of(ctx).pop(), + child: const ModelsPanel(), + ), + ), + ), + ], + ); + }, + ); + } + Widget _left(BuildContext context) { return SizedBox( height: 38, @@ -192,25 +239,6 @@ class _ChatBoxState extends State { ); } - void _openModelDialog(BuildContext context) async { - final settings = context.read(); - final session = context.read(); - final selectedModel = settings.normalizeModelId(settings.settings.model); - - final result = await showDialog( - context: context, - builder: (context) => ModelPickerDialog( - models: selectableAiModels, - selectedModel: selectedModel, - ), - ); - - if (result != null) { - await settings.updateModel(result); - await session.updateSessionModel(result); - } - } - Widget _right(BuildContext context) { final settings = context.read(); final selectedModel = settings.normalizeModelId(settings.settings.model); @@ -220,15 +248,14 @@ class _ChatBoxState extends State { height: 38, child: Row( children: [ + ConstrainedBox( - constraints: BoxConstraints( + constraints: const BoxConstraints( maxWidth: 150, minHeight: double.infinity, ), child: AgcGhostButton( - borderRadius: BorderRadius.circular( - Theme.of(context).radiusLg - 4, - ), + borderRadius: BorderRadius.circular(Theme.of(context).radiusLg - 4), onPressed: () => _openModelDialog(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), @@ -245,8 +272,8 @@ class _ChatBoxState extends State { overflow: TextOverflow.ellipsis, ).small, ), - Gap(8), - Icon(LucideIcons.chevronsUpDown), + const Gap(8), + const Icon(LucideIcons.chevronsUpDown), ], ), ), @@ -335,6 +362,7 @@ class _ChatBoxState extends State { final contextTokens = chat.contextTokens; return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ LayoutBuilder( builder: (context, constraints) { @@ -425,32 +453,37 @@ class _ChatBoxState extends State { Divider(), ], - TextField( - controller: _controller, - focusNode: _focusNode, - borderRadius: Theme.of(context).borderRadiusLg, - placeholder: Text("Ask the agency anything"), - minLines: 1, - maxLines: numberOfLines > 1 ? 5 : 1, - clipBehavior: Clip.hardEdge, - padding: EdgeInsets.all(8), - features: [ - if (_attachments.isNotEmpty) - InputFeature.above( - AttachmentPreview( - attachments: _attachments, - onRemove: _removeAttachment, + Stack( + alignment: Alignment.centerRight, + children: [ + TextField( + controller: _controller, + focusNode: _focusNode, + borderRadius: Theme.of(context).borderRadiusLg, + placeholder: Text("Ask the agency anything"), + minLines: 1, + maxLines: numberOfLines > 1 ? 5 : 1, + clipBehavior: Clip.hardEdge, + padding: EdgeInsets.all(8), + features: [ + if (_attachments.isNotEmpty) + InputFeature.above( + AttachmentPreview( + attachments: _attachments, + onRemove: _removeAttachment, + ), + ), + + InputFeature.leading( + _buildLeading(context, numberOfLines), ), - ), - InputFeature.leading( - _buildLeading(context, numberOfLines), - ), + InputFeature.trailing(_buildTrailing(numberOfLines)), - InputFeature.trailing(_buildTrailing(numberOfLines)), - - InputFeature.below( - _buildBottom(context, numberOfLines), + InputFeature.below( + _buildBottom(context, numberOfLines), + ), + ], ), ], ), @@ -468,9 +501,24 @@ class _ChatBoxState extends State { }, ), - const SizedBox(height: 6), + Gap(10), + + Row( + children: [ + + SizedBox( + // width: 130, + child: _PermissionModeSelector() + ), + + Gap(10), + + _ProjectSelector(), + + ], + ) + - _PermissionModeSelector(), ], ); @@ -490,81 +538,115 @@ class _PermissionModeSelector extends StatelessWidget { Widget build(BuildContext context) { final chat = context.watch(); final current = chat.threadPermissionMode; - final theme = Theme.of(context); - final mutedFg = theme.colorScheme.mutedForeground; - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - - for (int i = 0; i < _modes.length; i++) ...[ - - if (i > 0) const SizedBox(width: 4), - - _ModeChip( - label: _modes[i].$2, - selected: current == _modes[i].$1, - mutedFg: mutedFg, - onTap: () => chat.setThreadPermissionMode(_modes[i].$1), - ), - - ], - - ], + final currentEntry = _modes.firstWhere( + (m) => m.$1 == current, + orElse: () => ("default", "Ask Always"), ); - } -} -class _ModeChip extends StatefulWidget { - const _ModeChip({ - required this.label, - required this.selected, - required this.mutedFg, - required this.onTap, - }); - - final String label; - final bool selected; - final Color mutedFg; - final VoidCallback onTap; - - @override - State<_ModeChip> createState() => _ModeChipState(); -} - -class _ModeChipState extends State<_ModeChip> { - bool _hovering = false; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final fg = widget.selected - ? theme.colorScheme.foreground - : widget.mutedFg; - - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: widget.selected - ? theme.colorScheme.secondary - : _hovering - ? theme.colorScheme.secondary.withValues(alpha: 0.5) - : Colors.transparent, - borderRadius: BorderRadius.circular(theme.radiusSm), - ), - child: Text( - widget.label, - style: TextStyle(fontSize: 11, color: fg, fontWeight: widget.selected ? FontWeight.w600 : FontWeight.normal), - ), + return Select<(String, String)>( + itemBuilder: (ctx, item) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_iconFor(item.$1), size: 12), + Gap(8), + Text(item.$2, style: const TextStyle(fontSize: 11)), + ], + ), + onChanged: (v) { + if (v == null) return; + chat.setThreadPermissionMode(v.$1); + }, + value: currentEntry, + popupConstraints: const BoxConstraints(maxWidth: 300, minWidth: 300), + popup: SelectPopup( + items: SelectItemList( + children: [ + for (final mode in _modes) + SelectItemButton( + value: mode, + child: Row( + children: [ + Icon(_iconFor(mode.$1), size: 12), + Gap(8), + Text(mode.$2, style: const TextStyle(fontSize: 11)), + ], + ), + ), + ], ), ), ); } + + static IconData _iconFor(String mode) { + switch (mode) { + case "acceptEdits": return LucideIcons.checkCheck; + default: return LucideIcons.shieldQuestion; + } + } +} + + +class _ProjectSelector extends StatelessWidget { + const _ProjectSelector(); + + @override + Widget build(BuildContext context) { + final projectsProvider = context.watch(); + final projects = projectsProvider.projects; + final selected = projectsProvider.selectedProject; + final coordinator = context.read(); + final hasMessages = context.watch().messageCount > 0; + + return Select( + enabled: !hasMessages, + itemBuilder: (context, item) => Row( + children: [ + Icon(LucideIcons.folder).iconSmall, + Gap(8), + Text(item.name, style: const TextStyle(fontSize: 11)), + ], + ), + popupConstraints: BoxConstraints( + maxWidth: 320, + minWidth: 320, + maxHeight: 200 + ), + popupWidthConstraint: PopoverConstraint.flexible, + 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).small, + Text(project.workingDirectory).muted.xSmall, + ], + ), + ), + ], + ); + }, + ), + onChanged: (project) { + if (project != null) coordinator.selectProject(project); + }, + constraints: const BoxConstraints(minWidth: 180, maxWidth: 240), + value: selected, + placeholder: const Text("Select project", style: TextStyle(fontSize: 11)), + ); + } } diff --git a/lib/ui/widgets/chat/chat_view.dart b/lib/ui/widgets/chat/chat_view.dart index f11b0b5..82dde0c 100644 --- a/lib/ui/widgets/chat/chat_view.dart +++ b/lib/ui/widgets/chat/chat_view.dart @@ -19,8 +19,14 @@ class ChatView extends StatefulWidget { class _ChatViewState extends State { ScrollController get _scrollController => widget.scrollController; bool _autoScrollQueued = false; + + // cached entries — only rebuilt when message list actually changes + List _cachedMessages = const []; + List<_ChatEntry> _cachedEntries = const []; + + // track last message identity + count to detect changes cheaply int _lastMessageCount = 0; - List _lastMessageSigs = const []; + Object? _lastMessageTail; // identity of the last message object static const double _bottomThreshold = 56; @@ -40,30 +46,33 @@ class _ChatViewState extends State { }); } + List<_ChatEntry> _getEntries(List messages) { + final tail = messages.isEmpty ? null : messages.last; + final changed = messages.length != _lastMessageCount || !identical(tail, _lastMessageTail); + + if (changed) { + _cachedMessages = messages; + _cachedEntries = _buildEntries(messages); + _lastMessageCount = messages.length; + _lastMessageTail = tail; + } + return _cachedEntries; + } + @override Widget build(BuildContext context) { return Consumer( builder: (context, chatProvider, _) { final currentMessages = chatProvider.messages; - final currentSigs = currentMessages.map((m) => "${m.role}:${m.content}").toList(growable: false); - final messagesChanged = currentMessages.length != _lastMessageCount || - currentSigs.length != _lastMessageSigs.length || - !_listEquals(currentSigs, _lastMessageSigs); + + final prevCount = _lastMessageCount; + final entries = _getEntries(currentMessages); + final messagesChanged = currentMessages.length != prevCount; if (messagesChanged && currentMessages.isNotEmpty) { if (_isAtBottom()) _scheduleAutoScroll(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _lastMessageCount = currentMessages.length; - _lastMessageSigs = currentSigs; - }); - } else if (currentMessages.isEmpty) { - _lastMessageCount = 0; - _lastMessageSigs = const []; } - final entries = _buildEntries(currentMessages); - return Stack( children: [ ScrollConfiguration( @@ -71,16 +80,23 @@ class _ChatViewState extends State { child: SingleChildScrollView( controller: _scrollController, physics: const ClampingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var index = 0; index < entries.length; index++) - Padding( - key: ValueKey('${entries[index].stableKey}#$index'), - padding: const EdgeInsets.only(bottom: 12), - child: _buildBubble(context, chatProvider, entries[index], index, entries.length), - ), - ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Gap(12), + + for (var i = 0; i < entries.length; i++) + RepaintBoundary( + key: ValueKey('${entries[i].stableKey}#$i'), + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _buildBubble(context, chatProvider, entries[i], i, entries.length), + ), + ), + ], + ), ), ), ), @@ -97,13 +113,16 @@ class _ChatViewState extends State { if (entry is _MessageEntry) { final msg = entry.message; - if (msg.role == "user") return UserBubble(content: msg.content, attachments: msg.attachments); - if (msg.role == "assistant") return AssistantBubble(content: msg.content); + if (msg.role == "user") return UserBubble(content: msg.content, attachments: msg.attachments, timestamp: msg.timestamp); + if (msg.role == "assistant") { + final streaming = chatProvider.isLoading && index == total - 1; + return AssistantBubble(content: msg.content, isStreaming: streaming); + } return Text(msg.content); } if (entry is _ToolEntry) { - return ToolBubble(toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, isPendingPermission: isThisPending); + return ToolBubble(toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, pendingPermission: isThisPending ? pending : null); } return const SizedBox.shrink(); @@ -119,7 +138,14 @@ class _ChatViewState extends State { if (firstLine.endsWith(" call")) { final (toolName, toolInput) = ToolBubble.parseContent(msg.content); String? toolResult; - if (i + 1 < messages.length) { + + // check if streamed output was appended directly to this call message + // format: "Name call\n{json}\nchunks..." + final inlineResult = _extractInlineResult(msg.content); + if (inlineResult != null) { + toolResult = inlineResult; + } else if (i + 1 < messages.length) { + // normal non-streaming path: result is in the next message final next = messages[i + 1]; final nextFirst = next.content.split("\n").first.trim(); if (next.role == "tool" && nextFirst == "$toolName result") { @@ -135,6 +161,9 @@ class _ChatViewState extends State { final (toolName, _) = ToolBubble.parseContent(msg.content); result.add(_ToolEntry(toolName: toolName)); i++; + } else if (msg.role == "compact_boundary") { + // internal marker — not rendered, the assistant note after it says "compacted" + i++; } else { result.add(_MessageEntry(msg)); i++; @@ -143,14 +172,42 @@ class _ChatViewState extends State { return result; } - bool _listEquals(List a, List b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; + // if a call message has streamed output appended after its JSON body, + // extract it. format is: "Name call\n{json}\noutput..." + // returns null if there's no trailing content beyond the json block. + String? _extractInlineResult(String content) { + final newline = content.indexOf("\n"); + if (newline == -1) return null; + + final body = content.substring(newline + 1); + final jsonEnd = _jsonObjectEnd(body); + if (jsonEnd == -1) return null; + + final trailing = body.substring(jsonEnd + 1).trimLeft(); + return trailing.isEmpty ? null : trailing; } + + // walk the json object to find its true closing brace index + int _jsonObjectEnd(String s) { + int depth = 0; + bool inString = false; + for (int i = 0; i < s.length; i++) { + final c = s[i]; + if (inString) { + if (c == "\\") { i++; continue; } + if (c == "\"") inString = false; + } else { + if (c == "\"") inString = true; + else if (c == "{") depth++; + else if (c == "}") { + depth--; + if (depth == 0) return i; + } + } + } + return -1; + } + } sealed class _ChatEntry { diff --git a/lib/ui/widgets/chat/model_picker.dart b/lib/ui/widgets/chat/model_picker.dart index b3ac5e9..c9e1338 100644 --- a/lib/ui/widgets/chat/model_picker.dart +++ b/lib/ui/widgets/chat/model_picker.dart @@ -1,3 +1,4 @@ +// PARITY GAP: legacy model picker kept around while the new models panel takes over. import "package:flutter/material.dart"; import "package:provider/provider.dart"; diff --git a/lib/ui/widgets/chat/model_picker_dialog.dart b/lib/ui/widgets/chat/model_picker_dialog.dart index b9d5f8f..69a20ee 100644 --- a/lib/ui/widgets/chat/model_picker_dialog.dart +++ b/lib/ui/widgets/chat/model_picker_dialog.dart @@ -1,3 +1,4 @@ +// PARITY GAP: legacy dialog replaced by the settings-style models panel. import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../constants.dart"; diff --git a/lib/ui/widgets/chat/models_panel.dart b/lib/ui/widgets/chat/models_panel.dart new file mode 100644 index 0000000..c0417e0 --- /dev/null +++ b/lib/ui/widgets/chat/models_panel.dart @@ -0,0 +1,175 @@ +import "package:shadcn_flutter/shadcn_flutter.dart" as shad; +import "package:provider/provider.dart"; + +import "../../constants.dart"; +import "../../providers/session_provider.dart"; +import "../../providers/settings_provider.dart"; +import "../common/panel_layout.dart"; +import "../../../src/local_state.dart"; + + +class ModelsPanel extends shad.StatelessWidget { + const ModelsPanel({super.key}); + + @override + shad.Widget build(shad.BuildContext context) { + final settings = context.watch(); + final currentModel = settings.normalizeModelId(settings.settings.model); + final advisorModel = settings.settings.advisorModel; + final effortLevel = settings.settings.effortLevel; + final advisorEffortLevel = settings.settings.advisorEffortLevel; + + return shad.SizedBox( + height: 300, + child: shad.SingleChildScrollView( + child: PanelList( + fields: [ + PanelField( + section: "Conversation", + label: const shad.Text("Model"), + child: _modelSelect( + context: context, + value: currentModel, + onChanged: (model) async { + await context.read().updateModel(model); + await context.read().updateSessionModel(model); + }, + ), + ), + + PanelField( + section: "Conversation", + label: const shad.Text("Reasoning"), + child: _effortSelect( + context: context, + value: effortLevel, + onChanged: (level) async { + await context.read().updateEffortLevel(level ?? "medium"); + }, + ), + ), + + PanelField( + section: "Advisor", + label: const shad.Text("Model"), + child: _modelSelect( + context: context, + value: advisorModel ?? "", + placeholder: "None", + onChanged: (model) async { + await context.read().updateAdvisorModel(model); + }, + ), + ), + + PanelField( + section: "Advisor", + label: const shad.Text("Reasoning"), + child: _effortSelect( + context: context, + value: advisorEffortLevel, + onChanged: (level) async { + await context.read().updateAdvisorEffortLevel(level); + }, + ), + ), + ], + ), + ), + ); + } +} + + +Map> _groupedModels() { + final map = >{}; + for (final m in selectableAiModels) { + map.putIfAbsent(m.group, () => []).add(m); + } + return map; +} + +Iterable>> _filteredGroups(String query) sync* { + for (final entry in _groupedModels().entries) { + final matched = entry.value + .where((m) => m.label.toLowerCase().contains(query) || m.id.toLowerCase().contains(query)) + .toList(); + if (matched.isNotEmpty) { + yield MapEntry(entry.key, matched); + } else if (entry.key.toLowerCase().contains(query)) { + yield entry; + } + } +} + +shad.Widget _modelSelect({ + required shad.BuildContext context, + required String value, + String? placeholder, + required Future Function(String model) onChanged, +}) { + final current = value.isEmpty + ? null + : selectableAiModels.firstWhere( + (m) => m.id == value, + orElse: () => SelectableAiModel(group: "", id: value, label: value), + ); + + return shad.Select( + itemBuilder: (ctx, item) => _modelOption(item), + onChanged: (v) { + if (v == null) return; + onChanged(v.id); + }, + value: current, + placeholder: current != null + ? _modelOption(current) + : shad.Text(placeholder ?? "Select...").muted, + popup: shad.SelectPopup.builder( + searchPlaceholder: const shad.Text("Search models"), + builder: (context, searchQuery) { + final groups = searchQuery == null + ? _groupedModels().entries + : _filteredGroups(searchQuery.toLowerCase()); + + return shad.SelectItemList( + children: [ + for (final entry in groups) + shad.SelectGroup( + headers: [shad.SelectLabel(child: shad.Text(entry.key).xSmall.muted)], + children: [ + for (final m in entry.value) + shad.SelectItemButton(value: m, child: _modelOption(m)), + ], + ), + ], + ); + }, + ), + ); +} + +shad.Widget _modelOption(SelectableAiModel m) => shad.Text(m.label).xSmall; + + +shad.Widget _effortSelect({ + required shad.BuildContext context, + required String? value, + required Future Function(String? level) onChanged, +}) { + return shad.Select( + itemBuilder: (ctx, item) => shad.Text(item).xSmall, + onChanged: (v) => onChanged(v), + value: value, + placeholder: shad.Text(value ?? "none").xSmall.muted, + popup: shad.SelectPopup( + items: shad.SelectItemList( + children: [ + shad.SelectItemButton(value: null, child: shad.Text("none").xSmall), + for (final level in supportedEffortLevels) + shad.SelectItemButton(value: level, child: shad.Text(level).xSmall), + ], + ), + ), + ); +} diff --git a/lib/ui/widgets/common/pane_dialog.dart b/lib/ui/widgets/common/pane_dialog.dart new file mode 100644 index 0000000..0306625 --- /dev/null +++ b/lib/ui/widgets/common/pane_dialog.dart @@ -0,0 +1,156 @@ +import "package:flutter/widgets.dart"; +import "package:shadcn_flutter/shadcn_flutter.dart" as shad; + + +// floating pane visual — use inside showDialog, backdrop handled by caller +class PaneDialog extends StatelessWidget { + const PaneDialog({ + required this.title, + required this.child, + this.onClose, + this.fillHeight = false, + super.key, + }); + + final String title; + final Widget child; + final VoidCallback? onClose; + final bool fillHeight; + + @override + Widget build(BuildContext context) { + final scheme = shad.Theme.of(context).colorScheme; + final borderColor = Color.lerp(scheme.border, scheme.foreground, 0.1)!; + + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.background, + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow(color: Color(0x26888888), blurRadius: 4, spreadRadius: 2), + ], + ), + + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Column( + mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TitleBar(title: title, borderColor: borderColor, onClose: onClose), + child, + ], + ), + ), + + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor, width: 1), + ), + ), + ), + ), + ], + ), + ); + } +} + + +class _TitleBar extends StatelessWidget { + const _TitleBar({ + required this.title, + required this.borderColor, + this.onClose, + }); + + final String title; + final Color borderColor; + final VoidCallback? onClose; + + @override + Widget build(BuildContext context) { + final scheme = shad.Theme.of(context).colorScheme; + + return Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: scheme.secondary, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + border: Border(bottom: BorderSide(color: borderColor, width: 1)), + ), + child: Row( + children: [ + Text( + title, + style: shad.TextStyle( + color: scheme.secondaryForeground, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (onClose != null) _CloseBtn(onTap: onClose!), + ], + ), + ); + } +} + + +class _CloseBtn extends StatefulWidget { + const _CloseBtn({required this.onTap}); + final VoidCallback onTap; + + @override + State<_CloseBtn> createState() => _CloseBtnState(); +} + +class _CloseBtnState extends State<_CloseBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final scheme = shad.Theme.of(context).colorScheme; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: _hovered ? 1.0 : 0.0), + duration: const Duration(milliseconds: 20), + builder: (context, t, _) { + return Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Color.lerp( + const Color(0x00000000), + scheme.destructive.withValues(alpha: 0.12), + t, + ), + borderRadius: BorderRadius.circular(4), + ), + child: shad.Icon( + shad.LucideIcons.x, + size: 13, + color: Color.lerp(scheme.mutedForeground, scheme.destructive, t), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/ui/widgets/common/panel_layout.dart b/lib/ui/widgets/common/panel_layout.dart new file mode 100644 index 0000000..ebb4959 --- /dev/null +++ b/lib/ui/widgets/common/panel_layout.dart @@ -0,0 +1,248 @@ +import "dart:math" as math; + +import "package:flutter/rendering.dart"; +import "package:shadcn_flutter/shadcn_flutter.dart" as shad; + + +bool _isCompactTouch(shad.BuildContext context) { + return shad.MediaQuery.sizeOf(context).width < 900; +} + +class PanelField { + const PanelField({ + required this.section, + required this.label, + required this.child, + this.enabled = true, + }); + + final String section; + final shad.Widget label; + final shad.Widget child; + final bool enabled; +} + +// auto-groups fields by section and renders them as PanelSection > PanelRow +class PanelList extends shad.StatelessWidget { + const PanelList({super.key, required this.fields}); + + final List fields; + + @override + shad.Widget build(shad.BuildContext context) { + final theme = shad.Theme.of(context); + + final Map> grouped = {}; + for (final f in fields) { + grouped.putIfAbsent(f.section, () => []).add(f); + } + + final entries = grouped.entries.toList(); + + return shad.Column( + crossAxisAlignment: shad.CrossAxisAlignment.start, + children: [ + for (int i = 0; i < entries.length; i++) ...[ + PanelSection( + title: entries[i].key, + children: entries[i] + .value + .map((f) => PanelRow(label: f.label, child: f.child, enabled: f.enabled)) + .toList(), + ), + + if (i < entries.length - 1) + shad.Divider(color: theme.colorScheme.border, height: 1), + ], + shad.Divider(color: theme.colorScheme.border, height: 1), + const shad.SizedBox(height: 10), + ], + ); + } +} + +class PanelSection extends shad.StatefulWidget { + const PanelSection({ + super.key, + required this.title, + required this.children, + }); + + final String title; + final List children; + + @override + shad.State createState() => _PanelSectionState(); +} + +class _PanelSectionState extends shad.State { + bool _expanded = true; + + @override + shad.Widget build(shad.BuildContext context) { + final theme = shad.Theme.of(context); + + return shad.Column( + crossAxisAlignment: shad.CrossAxisAlignment.start, + children: [ + shad.SizedBox( + child: shad.GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + behavior: shad.HitTestBehavior.opaque, + child: shad.ColoredBox( + color: theme.colorScheme.secondary, + child: shad.Padding( + padding: const shad.EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: shad.Row( + children: [ + shad.AnimatedRotation( + turns: _expanded ? 0.0 : -0.25, + duration: const Duration(milliseconds: 120), + child: shad.Icon( + shad.LucideIcons.chevronDown, + size: 12, + color: theme.colorScheme.mutedForeground, + ), + ), + const shad.SizedBox(width: 4), + shad.Text( + widget.title, + style: shad.TextStyle( + fontSize: 11, + fontWeight: shad.FontWeight.w600, + color: theme.colorScheme.foreground, + letterSpacing: 0.4, + ), + ), + ], + ), + ), + ), + ), + ), + shad.Divider(color: theme.colorScheme.border, height: 1), + if (_expanded) + shad.Column( + crossAxisAlignment: shad.CrossAxisAlignment.start, + children: [ + for (int i = 0; i < widget.children.length; i++) ...[ + widget.children[i], + if (i < widget.children.length - 1) + shad.Divider(color: theme.colorScheme.border, height: 1), + ], + ], + ), + ], + ); + } +} + +class PanelRow extends shad.StatelessWidget { + const PanelRow({super.key, required this.label, required this.child, this.enabled = true}); + + final shad.Widget label; + final shad.Widget child; + final bool enabled; + + @override + shad.Widget build(shad.BuildContext context) { + final isCompactTouch = _isCompactTouch(context); + final theme = shad.Theme.of(context); + + final row = shad.ConstrainedBox( + constraints: shad.BoxConstraints(minHeight: isCompactTouch ? 52 : 38), + child: shad.IntrinsicHeight( + child: shad.Padding( + padding: const shad.EdgeInsets.only(left: 12), + child: shad.Row( + crossAxisAlignment: shad.CrossAxisAlignment.stretch, + children: [ + shad.SizedBox( + width: 100, + child: shad.Align( + alignment: shad.Alignment.centerLeft, + child: shad.Padding( + padding: shad.EdgeInsets.symmetric(vertical: isCompactTouch ? 6 : 4), + child: shad.DefaultTextStyle.merge( + style: shad.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + decoration: !enabled ? shad.TextDecoration.lineThrough : null, + decorationThickness: !enabled ? 3 : null, + ), + child: label, + ), + ), + ), + ), + shad.VerticalDivider(color: theme.colorScheme.border, width: 1), + shad.Expanded( + child: shad.Padding( + padding: shad.EdgeInsets.zero, + child: shad.Stack( + children: [ + shad.Positioned.fill( + child: shad.Padding( + padding: shad.EdgeInsets.symmetric(vertical: isCompactTouch ? 6 : 4, horizontal: 8), + child: child, + ), + ), + if (!enabled) + shad.Positioned.fill( + child: shad.GestureDetector( + onTap: () {}, + behavior: shad.HitTestBehavior.opaque, + child: shad.CustomPaint( + painter: _DisabledStripePainter(theme.colorScheme.border), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + + return row; + } +} + +class _DisabledStripePainter extends CustomPainter { + const _DisabledStripePainter(this.color); + + final shad.Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color.withValues(alpha: 1) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + const spacing = 4.0; + final diag = math.sqrt(size.width * size.width + size.height * size.height); + final count = (diag / spacing).ceil() + 2; + + canvas.save(); + canvas.clipRect(Offset.zero & size); + + canvas.drawRect(Rect.fromLTWH(1, 1, size.width - 3, size.height - 2), paint); + + canvas.translate(0, size.height); + canvas.rotate(-math.pi / 4); + + for (int i = -count; i <= count; i++) { + final x = i * spacing; + canvas.drawLine(Offset(x, -diag), Offset(x, diag), paint); + } + + canvas.restore(); + } + + @override + bool shouldRepaint(_DisabledStripePainter old) => old.color != color; +} diff --git a/lib/ui/widgets/common/settings_sheet.dart b/lib/ui/widgets/common/settings_sheet.dart index 0cb0620..5fbd695 100644 --- a/lib/ui/widgets/common/settings_sheet.dart +++ b/lib/ui/widgets/common/settings_sheet.dart @@ -2,7 +2,7 @@ import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../providers/settings_provider.dart"; -import "../chat/model_picker.dart"; +import "../chat/models_panel.dart"; class SettingsSheet extends StatelessWidget { const SettingsSheet(); @@ -23,13 +23,7 @@ class SettingsSheet extends StatelessWidget { ), const SizedBox(height: 16), - // model picker - const Text( - "Model", - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 8), - const ModelPicker(), + const ModelsPanel(), const SizedBox(height: 16), // API key setting @@ -64,7 +58,7 @@ class SettingsSheet extends StatelessWidget { ), const SizedBox(height: 8), _SimpleDropdown( - value: settingsProvider.settings.effortLevel ?? "medium", + value: settingsProvider.settings.effortLevel, items: const ["low", "medium", "high", "max"], onChanged: (newLevel) { settingsProvider.updateEffortLevel(newLevel); diff --git a/lib/ui/widgets/sidebar/account_button.dart b/lib/ui/widgets/sidebar/account_button.dart new file mode 100644 index 0000000..924e8a1 --- /dev/null +++ b/lib/ui/widgets/sidebar/account_button.dart @@ -0,0 +1,52 @@ + +import 'package:clawd_code/ui/widgets/common/button.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class AccountButton extends StatelessWidget { + + @override + Widget build(BuildContext context) { + // TODO: implement build + return AgcGhostButton( + onPressed: () { + + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + + Avatar( + initials: "BW", + ), + + Gap(12), + + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Benjamin Watt" + ).small, + + Text( + "Pro plan" + ).xSmall.muted + + ], + ), + + Spacer(), + + Icon( + LucideIcons.ellipsisVertical + ), + + Gap(10), + ], + ), + ), + ); + } + +} \ No newline at end of file diff --git a/lib/ui/widgets/sidebar/sidebar_v2.dart b/lib/ui/widgets/sidebar/sidebar_v2.dart index 413351e..9c092c8 100644 --- a/lib/ui/widgets/sidebar/sidebar_v2.dart +++ b/lib/ui/widgets/sidebar/sidebar_v2.dart @@ -1,3 +1,5 @@ +import "package:clawd_code/ui/widgets/sidebar/account_button.dart"; +import "package:flutter/services.dart" show Clipboard, ClipboardData; import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; @@ -11,29 +13,69 @@ import "app_logo.dart"; import "../common/button.dart"; class SidebarV2 extends StatelessWidget { - const SidebarV2({super.key}); + final VoidCallback? onClose; + + const SidebarV2({super.key, this.onClose}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( - width: 300, + width: 320, color: theme.colorScheme.input.scaleAlpha(0.3), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + + if (onClose != null) ...[ + Container( + alignment: Alignment.topLeft, + padding: EdgeInsets.all(8), + child: IconButton.ghost( + onPressed: onClose, + icon: Icon( + LucideIcons.panelLeftClose, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ], + Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28), - child: AppLogo(), + padding: const EdgeInsets.only( + left: 16, bottom: 22 + ), + child: Row( + children: [ + + Expanded(child: AppLogo()), + + ], + ), ), // Divider(color: theme.colorScheme.border, height: 1), _ActionsSection(), - Divider(color: theme.colorScheme.border, height: 1), + // Divider(color: theme.colorScheme.border, height: 1), - Expanded(child: _ProjectsSection()), + Divider(), + + Expanded( + child: Padding( + padding: const EdgeInsets.all(4), + child: OutlinedContainer( + borderRadius: BorderRadius.zero, + child: _ProjectsSection() + ), + ) + ), + + Divider(), + + AccountButton(), ], ), ); @@ -68,7 +110,7 @@ class _ActionsSection extends StatelessWidget { onTap: coordinator.pickProjectDirectory, ), - Divider(color: theme.colorScheme.border, height: 1), + // Divider(color: theme.colorScheme.border, height: 1), ], ); } @@ -132,6 +174,11 @@ class _ProjectsSection extends StatelessWidget { physics: const ClampingScrollPhysics(), padding: EdgeInsets.zero, children: [ + + // _SectionHeader(title: "PROJECTS", large: true), + // + // Divider(color: theme.colorScheme.background, height: 1), + for (final project in projects) ...[ _CollapsibleProjectSection( projectName: project.name, @@ -224,7 +271,9 @@ class _CollapsibleProjectSectionState ), ), - Divider(color: theme.colorScheme.border, height: 1), + if (_expanded) ...[ + Divider(color: theme.colorScheme.background, height: 1), + ], if (_expanded) ...[ if (widget.sessions.isEmpty) @@ -258,25 +307,38 @@ class _CollapsibleProjectSectionState class _SectionHeader extends StatelessWidget { final String title; + final bool large; - const _SectionHeader({required this.title}); + const _SectionHeader({required this.title, this.large = false}); @override Widget build(BuildContext context) { final theme = Theme.of(context); + TextStyle style; + if (large) { + style = TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: theme.colorScheme.foreground, + letterSpacing: 0.4, + ); + } else { + style = TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.colorScheme.foreground, + letterSpacing: 0.4, + ); + } + return ColoredBox( color: theme.colorScheme.secondary, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Text( title, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: theme.colorScheme.foreground, - letterSpacing: 0.4, - ), + style: style ), ), ); @@ -296,12 +358,12 @@ class _PanelItem extends StatelessWidget { final muted = onTap == null; return Padding( - padding: const EdgeInsets.all(1), + padding: const EdgeInsets.all(0), child: AgcGhostButton( onPressed: onTap, borderRadius: BorderRadius.zero, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ Icon( @@ -384,6 +446,16 @@ class _ThreadItem extends StatelessWidget { return ContextMenu( items: [ + MenuButton( + leading: const Icon(LucideIcons.copy).iconSmall, + onPressed: (_) { + final dir = session.workingDirectory ?? ""; + final path = "$dir/.the_agency/sessions/${session.id}.json"; + Clipboard.setData(ClipboardData(text: path)); + }, + child: const Text("Copy session path"), + ), + MenuButton( leading: const Icon(LucideIcons.trash2).iconSmall, onPressed: (_) => onDelete(), @@ -391,7 +463,7 @@ class _ThreadItem extends StatelessWidget { ), ], child: Container( - margin: EdgeInsets.all(1), + // margin: EdgeInsets.all(1), decoration: BoxDecoration( color: glowColor != null ? glowColor.withAlpha(selected ? 55 : 30) diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 46adacd..9c5d04d 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,6 +2,8 @@ + com.apple.security.app-sandbox + com.apple.security.cs.allow-jit com.apple.security.network.client diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index c80d0bd..04315f3 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,6 +2,8 @@ + com.apple.security.app-sandbox + com.apple.security.network.client com.apple.security.files.user-selected.read-write diff --git a/pubspec.lock b/pubspec.lock index e8c1cda..ff4cae9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -336,6 +344,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" intl: dependency: transitive description: @@ -400,14 +416,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - lucide_icons: - dependency: "direct main" - description: - name: lucide_icons - sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 - url: "https://pub.dev" - source: hosted - version: "0.257.0" markdown: dependency: transitive description: @@ -600,6 +608,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" provider: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ba490cd..1dc8e5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,9 +20,9 @@ dependencies: path: ^1.9.0 provider: ^6.1.2 shadcn_flutter: ^0.0.52 - lucide_icons: ^0.257.0 uuid: ^4.0.0 diff_match_patch: ^0.4.1 + image: ^4.2.0 gpt_markdown: ^1.1.6 yaml: ^3.1.2 glob: ^2.1.2