diff --git a/.claude/agents/a-agent-like-linus-keep-it-sim.md b/.claude/agents/a-agent-like-linus-keep-it-sim.md new file mode 100644 index 0000000..5773ebd --- /dev/null +++ b/.claude/agents/a-agent-like-linus-keep-it-sim.md @@ -0,0 +1,8 @@ +--- +name: a-agent-like-linus-keep-it-sim +description: "Use this agent when you need assistance with: a agent like linus, keep it simaple and stupid " +model: glm-4.5 +color: pink +--- + +You are a specialized assistant focused on helping with a agent like linus, keep it simaple and stupid . Provide expert-level assistance in this domain. \ No newline at end of file diff --git a/.claude/agents/dao-qi-harmony-designer.md b/.claude/agents/dao-qi-harmony-designer.md new file mode 100644 index 0000000..e6452bb --- /dev/null +++ b/.claude/agents/dao-qi-harmony-designer.md @@ -0,0 +1,8 @@ +--- +name: dao-qi-harmony-designer +description: "This agent should be used when designing, evaluating, or refining software products to ensure harmony between core principles (Dao) and user interface (Qi). It's particularly valuable during the conceptual phase of product development, when redesigning existing systems, or when trying to improve user adoption by making complex systems more intuitive. Use it when you want to create products where users can naturally grasp the underlying logic through the interface, or when you need to identify disconnects between your system's conceptual foundation and its implementation." +tools: "*" +color: red +--- + +You are the Dao-Qi Harmony Designer, an AI agent specialized in applying the Chinese philosophical concepts of Dao (道) and Qi (器) to software design. Your purpose is to help achieve perfect harmony between a system's underlying principles (Dao) and its concrete implementation (Qi).When analyzing a software product or design:1. First identify the Dao - the core concepts, mental models, information architecture, logic, and data flows that form the invisible foundation of the system.2. Then examine the Qi - the UI elements, interactions, visual designs, and user experiences that give form to these concepts.3. Evaluate how well the Qi expresses the Dao, looking for both strengths and misalignments.4. Provide specific recommendations to achieve '道器统一' (unity of Dao and Qi), ensuring the interface makes the underlying principles intuitive and accessible.Always remember that '道生器,器载道' (Dao gives birth to Qi, Qi carries Dao). The best designs emerge naturally from a clear understanding of fundamental principles. Guide users to first establish a strong Dao, then let the Qi emerge organically from it.Avoid solutions that focus only on beautiful interfaces without solid logic (Qi without Dao) or excellent concepts trapped in poor implementations (Dao without Qi). Instead, strive for designs where users can intuitively grasp the system's essence through its interface. \ No newline at end of file diff --git a/.claude/agents/simplicity-auditor.md b/.claude/agents/simplicity-auditor.md new file mode 100644 index 0000000..48031f3 --- /dev/null +++ b/.claude/agents/simplicity-auditor.md @@ -0,0 +1,9 @@ +--- +name: simplicity-auditor +description: "Use this agent when you need a thorough code review focused on identifying over-engineering, unnecessary abstractions, and unused logic. This agent is particularly valuable during code refactoring, architecture design phases, or when reviewing pull requests to ensure the codebase remains simple, maintainable, and practical. It's especially helpful for teams that tend to over-engineer solutions or create complex frameworks for problems that don't require them. The agent will challenge design decisions that add complexity without providing proportional value." +tools: "*" +model: glm-4.5 +color: yellow +--- + +You are a code reviewer with the philosophy of Linus Torvalds - you value simplicity, practicality, and direct solutions over complex abstractions and over-engineering. Your primary goal is to identify and eliminate unnecessary complexity in code.When reviewing code, focus on:1. Identifying over-engineered solutions that could be simplified2. Pointing out unnecessary abstractions that don't provide clear value3. Flagging unused code, functions, or logic that should be removed4. Challenging complex designs when simpler alternatives would work5. Ensuring code only solves actual problems, not hypothetical future onesAdopt the 'Keep it simple & stupid' (KISS) principle in all your feedback. Be direct, sometimes blunt, but always constructive. Question the necessity of each component and prefer straightforward implementations over clever ones.Remember: Good code solves the problem at hand with minimal complexity. Extra features, abstractions, or flexibility 'just in case' they're needed later are usually a waste of time and make the code harder to maintain. Only design what you need to use right now. \ No newline at end of file diff --git a/.claude/agents/test-agent.md b/.claude/agents/test-agent.md new file mode 100644 index 0000000..62b0428 --- /dev/null +++ b/.claude/agents/test-agent.md @@ -0,0 +1,9 @@ +--- +name: test-agent +description: Test agent for validation +tools: '*' +model: claude-3-5-sonnet-20241022 +color: cyan +--- + +You are a test agent. \ No newline at end of file diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md new file mode 100644 index 0000000..8d64cb1 --- /dev/null +++ b/.claude/agents/test-writer.md @@ -0,0 +1,32 @@ +--- +name: test-writer +description: "Specialized in writing comprehensive test suites. Use for creating unit tests, integration tests, and test documentation." +tools: ["FileRead", "FileWrite", "FileEdit", "Bash", "Grep"] +model: glm-4.5 +--- + +You are a test writing specialist. Your role is to create comprehensive, well-structured test suites. + +Your testing expertise includes: +- Writing unit tests with proper mocking and assertions +- Creating integration tests that verify component interactions +- Developing end-to-end tests for critical user workflows +- Generating test fixtures and test data +- Writing test documentation and coverage reports + +Testing guidelines: +- Follow the project's existing test patterns and conventions +- Ensure high code coverage while avoiding redundant tests +- Write clear test descriptions that explain what is being tested and why +- Include edge cases and error scenarios +- Use appropriate assertion methods and matchers +- Mock external dependencies appropriately +- Keep tests isolated and independent + +When writing tests: +1. First understand the code being tested +2. Identify key behaviors and edge cases +3. Structure tests using describe/it blocks or equivalent +4. Write clear, descriptive test names +5. Include setup and teardown when needed +6. Verify the tests pass by running them \ No newline at end of file diff --git a/.gitignore b/.gitignore index 21c9261..9dd2e62 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* -KODE.md +AGENTS.md # Caches diff --git a/.kode/agents/code-writer.md b/.kode/agents/code-writer.md new file mode 100644 index 0000000..330be0d --- /dev/null +++ b/.kode/agents/code-writer.md @@ -0,0 +1,28 @@ +--- +name: code-writer +description: Specialized in writing and modifying code, implementing features, fixing bugs, and refactoring +tools: ["Read", "Write", "Edit", "MultiEdit", "Bash"] +color: blue +--- + +You are a code writing specialist focused on implementing features, fixing bugs, and refactoring code. + +Your primary responsibilities: +1. Write clean, maintainable, and well-tested code +2. Follow existing project conventions and patterns +3. Implement features according to specifications +4. Fix bugs with minimal side effects +5. Refactor code to improve quality and maintainability + +Guidelines: +- Always understand the existing code structure before making changes +- Write code that fits naturally with the surrounding codebase +- Consider edge cases and error handling +- Keep changes focused and avoid scope creep +- Test your changes when possible + +When implementing features: +- Start by understanding the requirements fully +- Review existing similar code for patterns to follow +- Implement incrementally with clear commits +- Ensure backward compatibility where needed \ No newline at end of file diff --git a/.kode/agents/dao-qi-harmony-designer.md b/.kode/agents/dao-qi-harmony-designer.md new file mode 100644 index 0000000..11b182c --- /dev/null +++ b/.kode/agents/dao-qi-harmony-designer.md @@ -0,0 +1,27 @@ +--- +name: dao-qi-harmony-designer +description: Architecture and design harmony specialist that evaluates system coherence, design patterns, and architectural decisions +tools: ["Read", "Grep", "Glob", "LS"] +color: red +--- + +You are the Dao-Qi Harmony Designer, an architecture evaluation specialist focused on system coherence and design harmony. + +Your role is to evaluate and improve architectural designs based on principles of simplicity, clarity, and system-wide harmony. You examine codebases to identify architectural patterns, potential improvements, and ensure design consistency. + +When evaluating architecture: +1. Start by understanding the overall system structure +2. Identify key architectural patterns and design decisions +3. Look for inconsistencies or areas that break the harmony +4. Suggest improvements that enhance simplicity and maintainability +5. Consider both technical excellence and practical constraints + +Key focus areas: +- Component boundaries and responsibilities +- Data flow and state management patterns +- Separation of concerns +- Code organization and module structure +- Dependency management and coupling +- Interface design and API consistency + +Always provide specific examples from the codebase and concrete suggestions for improvement. \ No newline at end of file diff --git a/.kode/agents/docs-writer.md b/.kode/agents/docs-writer.md new file mode 100644 index 0000000..61f7ed3 --- /dev/null +++ b/.kode/agents/docs-writer.md @@ -0,0 +1,33 @@ +--- +name: docs-writer +description: "Documentation specialist for creating and updating technical documentation, README files, and API docs." +tools: ["FileRead", "FileWrite", "FileEdit", "Grep", "Glob"] +model: main +--- + +You are a documentation specialist. Your role is to create clear, comprehensive, and maintainable documentation. + +Your documentation expertise includes: +- Writing clear README files with installation and usage instructions +- Creating API documentation with examples +- Developing architecture and design documents +- Writing user guides and tutorials +- Creating inline code documentation and comments +- Generating changelog entries + +Documentation guidelines: +- Write for your target audience (developers, users, or both) +- Use clear, concise language avoiding unnecessary jargon +- Include practical examples and code snippets +- Structure documents with clear headings and sections +- Keep documentation in sync with the actual code +- Use diagrams and visuals where helpful +- Follow the project's documentation standards + +When creating documentation: +1. Understand the system or feature being documented +2. Identify the target audience and their needs +3. Organize information logically +4. Include all necessary details without overwhelming +5. Provide examples and use cases +6. Review for clarity and completeness \ No newline at end of file diff --git a/.kode/agents/search-specialist.md b/.kode/agents/search-specialist.md new file mode 100644 index 0000000..b33e6c4 --- /dev/null +++ b/.kode/agents/search-specialist.md @@ -0,0 +1,24 @@ +--- +name: search-specialist +description: Specialized in finding files and code patterns quickly using targeted searches +tools: ["Grep", "Glob", "Read", "LS"] +color: green +--- + +You are a search specialist optimized for quickly finding files, code patterns, and information in codebases. + +Your expertise: +1. Efficient pattern matching and search strategies +2. Finding code references and dependencies +3. Locating configuration files and documentation +4. Tracing function calls and data flow +5. Discovering hidden or hard-to-find code + +Search strategies: +- Start with broad searches and narrow down +- Use multiple search patterns if the first doesn't work +- Consider different naming conventions and variations +- Check common locations for specific file types +- Use context clues to refine searches + +Always aim to find all relevant occurrences, not just the first match. \ No newline at end of file diff --git a/.kode/agents/test-writer.md b/.kode/agents/test-writer.md new file mode 100644 index 0000000..7788c93 --- /dev/null +++ b/.kode/agents/test-writer.md @@ -0,0 +1,32 @@ +--- +name: test-writer +description: "Specialized in writing comprehensive test suites. Use for creating unit tests, integration tests, and test documentation." +tools: ["FileRead", "FileWrite", "FileEdit", "Bash", "Grep"] +model: main +--- + +You are a test writing specialist. Your role is to create comprehensive, well-structured test suites. + +Your testing expertise includes: +- Writing unit tests with proper mocking and assertions +- Creating integration tests that verify component interactions +- Developing end-to-end tests for critical user workflows +- Generating test fixtures and test data +- Writing test documentation and coverage reports + +Testing guidelines: +- Follow the project's existing test patterns and conventions +- Ensure high code coverage while avoiding redundant tests +- Write clear test descriptions that explain what is being tested and why +- Include edge cases and error scenarios +- Use appropriate assertion methods and matchers +- Mock external dependencies appropriately +- Keep tests isolated and independent + +When writing tests: +1. First understand the code being tested +2. Identify key behaviors and edge cases +3. Structure tests using describe/it blocks or equivalent +4. Write clear, descriptive test names +5. Include setup and teardown when needed +6. Verify the tests pass by running them \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..fb308e7 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,316 @@ +# Kode Subagent Implementation - Project Context + +## Project Overview + +### Mission +Implement a complete subagent system for Kode that achieves **100% alignment** with Claude Code's Task tool functionality, enabling dynamic agent configuration loading from markdown files with YAML frontmatter. + +### Core Architecture +Based on Claude Code's three-layer parallel architecture: +1. **User Interaction Layer** - REPL interface and commands +2. **Task Tool Layer** - Dynamic agent orchestration +3. **Tool Layer** - Individual tools (FileRead, Bash, etc.) + +## Implementation Summary + +### Key Components Implemented + +#### 1. Dynamic Agent Loader (`src/utils/agentLoader.ts`) +- **Purpose**: Load agent configurations from markdown files with YAML frontmatter +- **Five-tier Priority System**: Built-in < ~/.claude < ~/.kode < ./.claude < ./.kode +- **Features**: + - Memoized loading for performance + - Hot reload with file system watching + - Tool permission filtering + - Model override capabilities + +#### 2. TaskTool Integration (`src/tools/TaskTool/TaskTool.tsx`) +- **Purpose**: Modified TaskTool to use dynamic agent configurations +- **Key Changes**: + - Removed hardcoded `SUBAGENT_CONFIGS` + - Added dynamic `subagent_type` parameter validation + - Integrated with agent loader for real-time agent discovery + - Support for async tool description generation + +#### 3. Agent Management UI (`src/commands/agents.tsx`) +- **Purpose**: Interactive `/agents` command for agent CRUD operations +- **Features**: + - List all available agents with location indicators + - Create new agents with step-by-step wizard + - View agent details and system prompts + - Delete custom agents (preserves built-ins) + - Support for saving to all 4 directory locations + +#### 4. Claude Service Fix (`src/services/claude.ts`) +- **Critical Fix**: Modified tool description processing to support async functions +- **Problem**: `tool.description` was used directly instead of `await tool.description()` +- **Solution**: Added async handling with function type checking + +### Agent Configuration Format + +```markdown +--- +name: agent-name +description: "When to use this agent description" +tools: ["ToolName1", "ToolName2"] # or "*" for all tools +model: model-name # optional +--- + +System prompt content here... +Multi-line prompts supported. +``` + +### Directory Structure & Priority + +``` +Priority Order (later overrides earlier): +1. Built-in (code-embedded) +2. ~/.claude/agents/ (Claude user) +3. ~/.kode/agents/ (Kode user) +4. ./.claude/agents/ (Claude project) +5. ./.kode/agents/ (Kode project) +``` + +### Available Built-in Agents + +```typescript +// User-level agents (in ~/.kode/agents/) +- general-purpose: Multi-step tasks, research, complex questions +- search-specialist: File/code pattern finding (tools: Grep, Glob, FileRead, LS) +- code-writer: Implementation, debugging (tools: FileRead, FileWrite, FileEdit, MultiEdit, Bash) +- reviewer: Code quality analysis (tools: FileRead, Grep, Glob) +- architect: System design decisions (tools: FileRead, FileWrite, Grep, Glob) + +// Project-level agents (in ./.kode/agents/) +- test-writer: Test suite creation (tools: FileRead, FileWrite, FileEdit, Bash, Grep) +- docs-writer: Technical documentation (tools: FileRead, FileWrite, FileEdit, Grep, Glob) +``` + +## Critical Technical Details + +### 1. Async Description Pattern +```typescript +// WRONG (old pattern) +const toolSchemas = tools.map(tool => ({ + description: tool.description, // Function reference +})) + +// CORRECT (fixed pattern) +const toolSchemas = await Promise.all( + tools.map(async tool => ({ + description: typeof tool.description === 'function' + ? await tool.description() + : tool.description, + })) +) +``` + +### 2. Agent Loading Flow +```typescript +1. scanAgentDirectory() -> Parse .md files with gray-matter +2. loadAllAgents() -> Parallel scanning of all 4 directories +3. Priority override -> Map-based deduplication by agentType +4. Memoization -> LRU cache for performance +5. Hot reload -> FSWatcher on all directories +``` + +### 3. Tool Permission Filtering +```typescript +// In TaskTool.tsx +if (toolFilter && toolFilter !== '*') { + if (Array.isArray(toolFilter)) { + tools = tools.filter(tool => toolFilter.includes(tool.name)) + } +} +``` + +### 4. Model Override Logic +```typescript +// Priority: CLI model param > agent config > default +let effectiveModel = model || 'task' // CLI param +if (!model && agentConfig.model) { + effectiveModel = agentConfig.model // Agent config +} +``` + +## Standard Operating Procedures + +### SOP 1: Adding New Built-in Agent +1. Create `.md` file in appropriate directory +2. Use proper YAML frontmatter format +3. Test with `getActiveAgents()` function +4. Verify priority system works correctly +5. Update documentation if needed + +### SOP 2: Debugging Agent Loading Issues +```bash +# 1. Test agent loader directly +bun -e "import {getActiveAgents} from './src/utils/agentLoader'; console.log(await getActiveAgents())" + +# 2. Clear cache and reload +bun -e "import {clearAgentCache} from './src/utils/agentLoader'; clearAgentCache()" + +# 3. Check TaskTool description generation +bun -e "import {TaskTool} from './src/tools/TaskTool/TaskTool'; console.log(await TaskTool.description())" + +# 4. Verify directory structure +ls -la ~/.claude/agents/ ~/.kode/agents/ ./.claude/agents/ ./.kode/agents/ +``` + +### SOP 3: Testing Subagent System +```typescript +// Comprehensive test pattern +async function testSubagentSystem() { + // 1. Clear cache + clearAgentCache() + + // 2. Load agents + const agents = await getActiveAgents() + + // 3. Verify count and types + const types = await getAvailableAgentTypes() + + // 4. Test TaskTool integration + const description = await TaskTool.description() + + // 5. Verify priority system + const duplicates = findDuplicateAgentTypes(agents) + + return { agents, types, description, duplicates } +} +``` + +### SOP 4: Agent Management Best Practices +1. **Agent Naming**: Use kebab-case (`search-specialist`, not `SearchSpecialist`) +2. **Tool Selection**: Be specific about tool permissions for security +3. **Model Selection**: Only specify if different from default +4. **Description**: Clear, concise "when to use" guidance +5. **System Prompt**: Focus on capabilities and constraints + +## Key Learnings & Insights + +### 1. Claude Code Alignment Requirements +- **100% format compatibility**: YAML frontmatter + markdown body +- **Directory structure**: Support both `.claude` and `.kode` directories +- **Priority system**: Complex 5-tier hierarchy with proper override logic +- **Hot reload**: Real-time configuration updates without restart +- **Tool permissions**: Security through capability restriction + +### 2. Performance Considerations +- **Memoization**: Critical for avoiding repeated file I/O +- **Parallel loading**: All directories scanned concurrently +- **Caching strategy**: LRU cache with manual invalidation +- **File watching**: Efficient hot reload with minimal overhead + +### 3. Error Handling Patterns +```typescript +// Graceful degradation pattern +try { + const agents = await loadAllAgents() + return { activeAgents: agents.activeAgents, allAgents: agents.allAgents } +} catch (error) { + console.error('Failed to load agents, falling back to built-in:', error) + return { + activeAgents: [BUILTIN_GENERAL_PURPOSE], + allAgents: [BUILTIN_GENERAL_PURPOSE] + } +} +``` + +### 4. TypeScript Integration Points +```typescript +export interface AgentConfig { + agentType: string // Matches subagent_type parameter + whenToUse: string // User-facing description + tools: string[] | '*' // Tool permission filtering + systemPrompt: string // Injected into task prompt + location: 'built-in' | 'user' | 'project' + color?: string // Optional UI theming + model?: string // Optional model override +} +``` + +## Common Issues & Solutions + +### Issue 1: "Agent type 'X' not found" +**Cause**: Agent not loaded or wrong agentType in frontmatter +**Solution**: +1. Check file exists in expected directory +2. Verify `name:` field in YAML frontmatter +3. Clear cache with `clearAgentCache()` +4. Check file permissions + +### Issue 2: Tool description not showing subagent types +**Cause**: Async description function not being awaited +**Solution**: Ensure Claude service uses `await tool.description()` pattern + +### Issue 3: Priority system not working +**Cause**: Map iteration order or incorrect directory scanning +**Solution**: Verify loading order matches priority specification + +### Issue 4: Hot reload not triggering +**Cause**: File watcher not set up or wrong directory +**Solution**: Check `startAgentWatcher()` covers all 4 directories + +## Future Enhancement Opportunities + +### 1. Advanced Agent Features +- **Agent inheritance**: Base agents with specialized variants +- **Conditional logic**: Dynamic tool selection based on context +- **Agent composition**: Chaining agents for complex workflows +- **Performance metrics**: Track agent usage and effectiveness + +### 2. UI/UX Improvements +- **Visual agent editor**: Rich text editing for system prompts +- **Agent marketplace**: Share and discover community agents +- **Configuration validation**: Real-time feedback on agent configs +- **Usage analytics**: Show which agents are most effective + +### 3. Integration Enhancements +- **IDE integration**: VS Code extension for agent management +- **API endpoints**: REST API for external agent management +- **Version control**: Git integration for agent configuration history +- **Cloud sync**: Cross-device agent synchronization + +## Testing & Validation + +### Test Coverage Areas +1. **Agent Loading**: All directory combinations and priority scenarios +2. **Tool Filtering**: Security boundary enforcement +3. **Model Override**: CLI param vs agent config vs default +4. **Hot Reload**: File change detection and cache invalidation +5. **Error Handling**: Graceful degradation and recovery +6. **TaskTool Integration**: Dynamic description generation +7. **UI Components**: Agent management command workflows + +### Validation Checklist +- [ ] All 5 priority levels load correctly +- [ ] Duplicate agent names resolve to highest priority +- [ ] Tool permissions filter correctly +- [ ] Model overrides work in correct precedence +- [ ] Hot reload detects changes in all directories +- [ ] TaskTool description includes all available agents +- [ ] `/agents` command CRUD operations work +- [ ] Error states handled gracefully +- [ ] TypeScript types are correct and complete +- [ ] Documentation is accurate and comprehensive + +## Project Metrics & Success Criteria + +### Quantitative Metrics +- **Agent Load Performance**: < 100ms for typical configurations +- **Hot Reload Latency**: < 500ms from file change to cache update +- **Memory Usage**: < 50MB additional overhead for agent system +- **Test Coverage**: > 90% for core agent functionality +- **TypeScript Compliance**: 0 type errors in agent-related code + +### Qualitative Success Criteria +- **100% Claude Code compatibility**: All agent formats work identically +- **Seamless user experience**: No learning curve for existing Claude Code users +- **Robust error handling**: System degrades gracefully under all failure modes +- **Maintainable architecture**: Code is clean, documented, and extensible +- **Performance excellence**: No noticeable impact on Kode startup or operation + +--- + +*This document serves as the single source of truth for the Kode subagent implementation project. All team members should refer to this context when working on agent-related features or debugging issues.* \ No newline at end of file diff --git a/README.md b/README.md index f3725cc..f4d3ce0 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,65 @@ [![npm version](https://badge.fury.io/js/@shareai-lab%2Fkode.svg)](https://www.npmjs.com/package/@shareai-lab/kode) [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC) +[![AGENTS.md](https://img.shields.io/badge/AGENTS.md-Compatible-brightgreen)](https://agents.md) [中文文档](README.zh-CN.md) | [Contributing](CONTRIBUTING.md) | [Documentation](docs/) +## 🤝 AGENTS.md Standard Support + +**Kode proudly supports the [AGENTS.md standard protocol](https://agents.md) initiated by OpenAI** - a simple, open format for guiding programming agents that's used by 20k+ open source projects. + +### Full Compatibility with Multiple Standards + +- ✅ **AGENTS.md** - Native support for the OpenAI-initiated standard format +- ✅ **CLAUDE.md** - Full backward compatibility with Claude Code configurations +- ✅ **Subagent System** - Advanced agent delegation and task orchestration +- ✅ **Cross-platform** - Works with 20+ AI models and providers + +Use `# Your documentation request` to generate and maintain your AGENTS.md file automatically, while maintaining full compatibility with existing Claude Code workflows. + +## Overview + Kode is a powerful AI assistant that lives in your terminal. It can understand your codebase, edit files, run commands, and handle entire workflows for you. ## Features +### Core Capabilities - 🤖 **AI-Powered Assistance** - Uses advanced AI models to understand and respond to your requests - 🔄 **Multi-Model Collaboration** - Flexibly switch and combine multiple AI models to leverage their unique strengths +- 🦜 **Expert Model Consultation** - Use `@ask-model-name` to consult specific AI models for specialized analysis +- 👤 **Intelligent Agent System** - Use `@run-agent-name` to delegate tasks to specialized subagents - 📝 **Code Editing** - Directly edit files with intelligent suggestions and improvements - 🔍 **Codebase Understanding** - Analyzes your project structure and code relationships - 🚀 **Command Execution** - Run shell commands and see results in real-time - 🛠️ **Workflow Automation** - Handle complex development tasks with simple prompts + +### 🎯 Advanced Intelligent Completion System +Our state-of-the-art completion system provides unparalleled coding assistance: + +#### Smart Fuzzy Matching +- **Hyphen-Aware Matching** - Type `dao` to match `run-agent-dao-qi-harmony-designer` +- **Abbreviation Support** - `dq` matches `dao-qi`, `nde` matches `node` +- **Numeric Suffix Handling** - `py3` intelligently matches `python3` +- **Multi-Algorithm Fusion** - Combines 7+ matching algorithms for best results + +#### Intelligent Context Detection +- **No @ Required** - Type `gp5` directly to match `@ask-gpt-5` +- **Auto-Prefix Addition** - Tab/Enter automatically adds `@` for agents and models +- **Mixed Completion** - Seamlessly switch between commands, files, agents, and models +- **Smart Prioritization** - Results ranked by relevance and usage frequency + +#### Unix Command Optimization +- **500+ Common Commands** - Curated database of frequently used Unix/Linux commands +- **System Intersection** - Only shows commands that actually exist on your system +- **Priority Scoring** - Common commands appear first (git, npm, docker, etc.) +- **Real-time Loading** - Dynamic command discovery from system PATH + +### User Experience - 🎨 **Interactive UI** - Beautiful terminal interface with syntax highlighting - 🔌 **Tool System** - Extensible architecture with specialized tools for different tasks - 💾 **Context Management** - Smart context handling to maintain conversation continuity +- 📋 **AGENTS.md Integration** - Use `# documentation requests` to auto-generate and maintain project documentation ## Installation @@ -50,6 +93,53 @@ kode -p "explain this function" main.js kwa -p "explain this function" main.js ``` +### Using the @ Mention System + +Kode supports a powerful @ mention system for intelligent completions: + +#### 🦜 Expert Model Consultation +```bash +# Consult specific AI models for expert opinions +@ask-claude-sonnet-4 How should I optimize this React component for performance? +@ask-gpt-5 What are the security implications of this authentication method? +@ask-o1-preview Analyze the complexity of this algorithm +``` + +#### 👤 Specialized Agent Delegation +```bash +# Delegate tasks to specialized subagents +@run-agent-simplicity-auditor Review this code for over-engineering +@run-agent-architect Design a microservices architecture for this system +@run-agent-test-writer Create comprehensive tests for these modules +``` + +#### 📁 Smart File References +```bash +# Reference files and directories with auto-completion +@src/components/Button.tsx +@docs/api-reference.md +@.env.example +``` + +The @ mention system provides intelligent completions as you type, showing available models, agents, and files. + +### AGENTS.md Documentation Mode + +Use the `#` prefix to generate and maintain your AGENTS.md documentation: + +```bash +# Generate setup instructions +# How do I set up the development environment? + +# Create testing documentation +# What are the testing procedures for this project? + +# Document deployment process +# Explain the deployment pipeline and requirements +``` + +This mode automatically formats responses as structured documentation and appends them to your AGENTS.md file. + ### Commands - `/help` - Show available commands diff --git a/docs/ELEGANT_TAB_IMPROVEMENT_PLAN.md b/docs/ELEGANT_TAB_IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..dbd3389 --- /dev/null +++ b/docs/ELEGANT_TAB_IMPROVEMENT_PLAN.md @@ -0,0 +1,270 @@ +# 优雅的Tab补全改进计划 + +## 一、当前架构分析 + +### 核心数据结构(保持不变) +```typescript +interface UnifiedSuggestion // ✅ 完美,不需要改动 +interface CompletionContext // ✅ 完美,不需要改动 +``` + +### 状态管理(需要增强) +```typescript +// 当前状态 +const [suggestions, setSuggestions] // ✅ 保持 +const [selectedIndex, setSelectedIndex] // ✅ 保持 +const [isActive, setIsActive] // ✅ 保持 +const lastTabContext = useRef() // ✅ 保持 + +// 需要添加的状态(最小化) +const tabState = useRef() // 🆕 Tab按键状态 +``` + +### 关键函数(大部分保持) +- `getWordAtCursor()` ✅ 完美,不改 +- `generateCommandSuggestions()` ✅ 完美,不改 +- `generateAgentSuggestions()` ✅ 完美,不改 +- `generateFileSuggestions()` ✅ 完美,不改 +- `generateSuggestions()` ✅ 完美,不改 +- Tab处理逻辑 ❌ 需要重构 + +## 二、最小化改动方案 + +### 1. 添加Tab状态跟踪(新增数据结构) +```typescript +// 添加到文件顶部,与其他interface并列 +interface TabState { + lastTabTime: number + consecutiveTabCount: number + lastPrefix: string + lastSuggestions: UnifiedSuggestion[] +} +``` + +### 2. 添加公共前缀计算(纯函数,无副作用) +```typescript +// 添加为独立的utility函数 +const findCommonPrefix = (suggestions: UnifiedSuggestion[]): string => { + if (suggestions.length === 0) return '' + if (suggestions.length === 1) return suggestions[0].value + + const values = suggestions.map(s => s.value) + let prefix = values[0] + + for (let i = 1; i < values.length; i++) { + while (prefix && !values[i].startsWith(prefix)) { + prefix = prefix.slice(0, -1) + } + if (!prefix) break + } + + return prefix +} +``` + +### 3. 重构Tab处理逻辑(核心改动) + +将现有的Tab处理(185-237行)替换为新的智能处理: + +```typescript +// Handle Tab key - Terminal-compliant behavior +useInput(async (_, key) => { + if (!key.tab || key.shift) return false + + const context = getWordAtCursor() + if (!context) return false + + const now = Date.now() + const isDoubleTab = tabState.current && + (now - tabState.current.lastTabTime) < 500 && + tabState.current.lastPrefix === context.prefix + + // 如果菜单已显示,Tab选择下一个 + if (isActive && suggestions.length > 0) { + // 保持原有逻辑 + const selected = suggestions[selectedIndex] + // ... 完成逻辑 + return true + } + + // 生成建议(只在需要时) + let currentSuggestions = suggestions + if (!isDoubleTab || suggestions.length === 0) { + currentSuggestions = await generateSuggestions(context) + } + + // 决策树 - 完全符合终端行为 + if (currentSuggestions.length === 0) { + // 无匹配:蜂鸣 + return false + + } else if (currentSuggestions.length === 1) { + // 唯一匹配:立即完成 + completeWith(currentSuggestions[0], context) + resetTabState() + return true + + } else { + // 多个匹配 + const commonPrefix = findCommonPrefix(currentSuggestions) + + if (commonPrefix.length > context.prefix.length) { + // 可以补全到公共前缀 + partialComplete(commonPrefix, context) + updateTabState(now, context.prefix, currentSuggestions) + return true + + } else if (isDoubleTab) { + // 第二次Tab:显示菜单 + setSuggestions(currentSuggestions) + setIsActive(true) + setSelectedIndex(0) + return true + + } else { + // 第一次Tab但无法补全:记录状态 + updateTabState(now, context.prefix, currentSuggestions) + return false // 蜂鸣 + } + } +}) +``` + +### 4. 添加辅助函数(与现有风格一致) + +```typescript +// 完成补全 +const completeWith = useCallback((suggestion: UnifiedSuggestion, context: CompletionContext) => { + const completion = context.type === 'command' ? `/${suggestion.value} ` : + context.type === 'agent' ? `@${suggestion.value} ` : + suggestion.value + + const newInput = input.slice(0, context.startPos) + completion + input.slice(context.endPos) + onInputChange(newInput) + setCursorOffset(context.startPos + completion.length) +}, [input, onInputChange, setCursorOffset]) + +// 部分补全 +const partialComplete = useCallback((prefix: string, context: CompletionContext) => { + const newInput = input.slice(0, context.startPos) + prefix + input.slice(context.endPos) + onInputChange(newInput) + setCursorOffset(context.startPos + prefix.length) +}, [input, onInputChange, setCursorOffset]) + +// Tab状态管理 +const updateTabState = useCallback((time: number, prefix: string, suggestions: UnifiedSuggestion[]) => { + tabState.current = { + lastTabTime: time, + consecutiveTabCount: (tabState.current?.consecutiveTabCount || 0) + 1, + lastPrefix: prefix, + lastSuggestions: suggestions + } +}, []) + +const resetTabState = useCallback(() => { + tabState.current = null +}, []) +``` + +## 三、实施步骤 + +### Phase 1: 基础设施(不影响现有功能) +1. 添加 `TabState` interface +2. 添加 `tabState` useRef +3. 添加 `findCommonPrefix` 函数 +4. 添加辅助函数 + +### Phase 2: 核心逻辑替换(原子操作) +1. 备份现有Tab处理代码 +2. 替换为新的决策树逻辑 +3. 测试所有场景 + +### Phase 3: 细节优化 +1. 调整超时时间(500ms vs 300ms) +2. 优化菜单显示格式 +3. 添加蜂鸣反馈(可选) + +## 四、影响评估 + +### 不变的部分(90%) +- 所有数据结构 +- 所有生成函数 +- 箭头键处理 +- Effect清理逻辑 +- 与PromptInput的接口 + +### 改变的部分(10%) +- Tab按键处理逻辑 +- 新增4个小函数 +- 新增1个状态ref + +### 风险评估 +- **低风险**:改动集中在一处 +- **可回滚**:逻辑独立,易于回滚 +- **向后兼容**:接口不变 + +## 五、测试场景 + +### 场景1: 多个文件补全 +```bash +# 文件: package.json, package-lock.json +输入: p[Tab] +期望: 补全到 "package" +输入: package[Tab][Tab] +期望: 显示菜单 +``` + +### 场景2: 唯一匹配 +```bash +输入: READ[Tab] +期望: 补全到 "README.md" +``` + +### 场景3: 连续补全 +```bash +输入: src/[Tab] +期望: 可以继续Tab补全 +``` + +## 六、代码风格指南 + +### 保持一致性 +- 使用 `useCallback` 包装所有函数 +- 使用 `as const` 断言类型 +- 保持简洁的注释风格 + +### 命名规范 +- 动词开头:`completeWith`, `updateTabState` +- 布尔值:`isDoubleTab`, `isActive` +- 常量大写:`TAB_TIMEOUT` + +### 错误处理 +- 保持静默失败(符合现有风格) +- 使用 try-catch 包装文件操作 + +## 七、预期效果 + +### Before +``` +cat p[Tab] +▸ package.json # 立即显示菜单 ❌ + package-lock.json +``` + +### After +``` +cat p[Tab] +cat package # 补全公共前缀 ✅ +cat package[Tab][Tab] +package.json package-lock.json # 双Tab显示 ✅ +``` + +## 八、总结 + +这个方案: +1. **最小化改动** - 90%代码不变 +2. **原子操作** - 可以一次性替换 +3. **风格一致** - 像原生代码 +4. **100%终端兼容** - 完全匹配bash行为 + +准备好实施了吗? \ No newline at end of file diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 8df9aa9..9391655 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -30,7 +30,12 @@ Clean, modern TypeScript CLI project using Bun for development and building. │ ├── tools/ # AI tool implementations │ ├── services/ # Core services │ ├── hooks/ # React hooks +│ │ └── useUnifiedCompletion.ts # Advanced completion system │ ├── utils/ # Utility functions +│ │ ├── advancedFuzzyMatcher.ts # 7+ algorithm fuzzy matcher +│ │ ├── fuzzyMatcher.ts # Matcher integration layer +│ │ ├── commonUnixCommands.ts # 500+ command database +│ │ └── agentLoader.ts # Agent configuration loader │ └── constants/ # Constants and configurations │ ├── docs/ # Documentation @@ -45,7 +50,7 @@ Clean, modern TypeScript CLI project using Bun for development and building. ├── README.md # English documentation ├── README.zh-CN.md # Chinese documentation ├── PUBLISH.md # Publishing guide -├── KODE.md # Project context (generated) +├── AGENTS.md # Project context (generated) └── system-design.md # System architecture doc (Chinese) ``` diff --git a/docs/TAB_BEHAVIOR_DEMO.md b/docs/TAB_BEHAVIOR_DEMO.md new file mode 100644 index 0000000..fbfc0de --- /dev/null +++ b/docs/TAB_BEHAVIOR_DEMO.md @@ -0,0 +1,186 @@ +# Tab 补全行为演示 + +## 🎯 核心洞察:Tab的两个职责 + +### 1️⃣ Tab = "尽可能补全" +### 2️⃣ Tab Tab = "显示所有选项" + +--- + +## 📱 实际例子(假设有文件:package.json, package-lock.json) + +### ✅ 标准终端的智慧 +```bash +cat p[Tab] +# 🤔 思考:有 package.json, package-lock.json, public/ +# 💡 决策:补全到公共前缀 +cat package█ + +cat package[Tab] +# 🤔 思考:还是有歧义 +# 💡 决策:不动(或蜂鸣) + +cat package[Tab][Tab] +# 🤔 思考:用户需要看选项了 +# 💡 决策:显示所有 +package.json package-lock.json + +cat package.j[Tab] +# 🤔 思考:唯一匹配! +# 💡 决策:直接补全 +cat package.json█ +``` + +### ❌ 我们现在的问题 +```bash +cat p[Tab] +# 😵 立即显示菜单 +▸ package.json + package-lock.json + public/ +# 用户:我只是想补全啊,不是要选择! +``` + +--- + +## 🧠 终端的补全决策树 + +``` +按下 Tab + ↓ +有几个匹配? + ↓ +┌────────┼────────┐ +0个 1个 多个 +↓ ↓ ↓ +蜂鸣 补全完成 有公共前缀? + ↓ + ┌────┴────┐ + 有 无 + ↓ ↓ + 补全前缀 是第二次Tab? + ↓ + ┌────┴────┐ + 是 否 + ↓ ↓ + 显示菜单 蜂鸣 +``` + +--- + +## 💭 为什么这样设计? + +### 效率原则 +- **最少按键**: 能补全就补全,不要问 +- **渐进显示**: 只在需要时才显示选项 +- **智能判断**: 根据上下文做最合理的事 + +### 用户心智模型 +``` +Tab = "帮我补全" +Tab Tab = "我需要看看有什么选项" +``` + +--- + +## 🔨 我们需要的改动 + +### 当前代码(过于简单) +```typescript +if (key.tab) { + if (suggestions.length > 1) { + showMenu() // ❌ 太早了! + } +} +``` + +### 应该的代码(智能判断) +```typescript +if (key.tab) { + const now = Date.now() + const isDoubleTab = (now - lastTabTime) < 300 + + if (suggestions.length === 0) { + beep() + } else if (suggestions.length === 1) { + complete(suggestions[0]) + } else { + // 多个匹配 + const commonPrefix = findCommonPrefix(suggestions) + const currentWord = getCurrentWord() + + if (commonPrefix.length > currentWord.length) { + // 可以补全到公共前缀 + complete(commonPrefix) + } else if (isDoubleTab) { + // 第二次Tab,显示菜单 + showMenu() + } else { + // 第一次Tab,但没有可补全的 + beep() + } + } + + lastTabTime = now +} +``` + +--- + +## 🎮 交互示例 + +### 场景:输入命令参数 +```bash +# 文件: README.md, README.txt, package.json + +$ cat R[Tab] +$ cat README # 补全公共前缀 + +$ cat README[Tab] +*beep* # 无法继续补全 + +$ cat README[Tab][Tab] +README.md README.txt # 显示选项 + +$ cat README.m[Tab] +$ cat README.md # 唯一匹配,完成 +``` + +### 场景:路径导航 +```bash +$ cd s[Tab] +$ cd src/ # 唯一匹配,补全+加斜杠 + +$ cd src/[Tab][Tab] +components/ hooks/ utils/ # 继续显示下级 + +$ cd src/c[Tab] +$ cd src/components/ # 继续补全 +``` + +--- + +## 📊 影响分析 + +| 操作 | 按键次数(现在) | 按键次数(改进后) | 节省 | +|------|-----------------|-------------------|------| +| 输入 package.json | 5次(p+Tab+↓+↓+Enter) | 3次(pa+Tab+.j+Tab) | 40% | +| 进入 src/components | 4次(s+Tab+Enter+c+Tab) | 2次(s+Tab+c+Tab) | 50% | +| 选择多个选项之一 | 3次(Tab+↓+Enter) | 4次(Tab+Tab+↓+Enter) | -33% | + +**平均效率提升:~30%** + +--- + +## 🚀 结论 + +**一个原则**:Tab应该"尽力而为",而不是"立即放弃" + +**两个规则**: +1. 能补全就补全 +2. 不能补全才显示选项(且需要双击) + +**三个好处**: +1. 符合用户期望 +2. 减少操作次数 +3. 保持专注流程 \ No newline at end of file diff --git a/docs/TERMINAL_BEHAVIOR_ANALYSIS.md b/docs/TERMINAL_BEHAVIOR_ANALYSIS.md new file mode 100644 index 0000000..c09cbed --- /dev/null +++ b/docs/TERMINAL_BEHAVIOR_ANALYSIS.md @@ -0,0 +1,251 @@ +# 终端Tab补全行为深度分析 + +## 一、标准终端(bash/zsh)的Tab补全行为 + +### 1. **单次Tab行为** +```bash +$ cat pa[Tab] +# 场景A:唯一匹配 +$ cat package.json # 立即补全,光标在末尾 + +# 场景B:多个匹配 +$ cat p[Tab] +# 无反应,需要按第二次Tab +``` + +### 2. **双击Tab行为** +```bash +$ cat p[Tab][Tab] +package.json package-lock.json public/ +$ cat p█ # 光标保持原位,显示所有可能选项 +``` + +### 3. **公共前缀补全** +```bash +$ cat pac[Tab] +$ cat package # 补全到公共前缀 "package" +$ cat package[Tab][Tab] +package.json package-lock.json +``` + +### 4. **路径补全特性** +```bash +# 自动添加斜杠 +$ cd src[Tab] +$ cd src/ # 目录自动加斜杠 + +# 连续补全 +$ cd src/[Tab] +components/ hooks/ utils/ # 显示目录内容 + +# 隐藏文件 +$ ls .[Tab][Tab] # 需要以.开头才显示隐藏文件 +.env .gitignore .npmrc +``` + +### 5. **上下文感知** +```bash +# 命令后的第一个参数 +$ git [Tab][Tab] +add commit push pull status # 显示git子命令 + +# 不同命令的不同行为 +$ cd [Tab] # 只显示目录 +$ cat [Tab] # 显示文件 +$ chmod [Tab] # 显示可执行文件 +``` + +### 6. **特殊字符处理** +```bash +# 空格转义 +$ cat My\ Documents/[Tab] +$ cat "My Documents/"[Tab] + +# 通配符 +$ cat *.js[Tab] # 展开所有.js文件 +$ cat test*[Tab] # 展开所有test开头的文件 +``` + +## 二、现代终端增强功能(fish/zsh with plugins) + +### 1. **实时建议(灰色文本)** +```bash +$ cat p +$ cat package.json # 灰色显示建议 +# 右箭头接受,Tab完成 +``` + +### 2. **智能历史** +```bash +$ npm run +$ npm run dev # 基于历史的建议 +``` + +### 3. **模糊匹配** +```bash +$ cat pjs[Tab] +$ cat package.json # 模糊匹配p...j...s +``` + +### 4. **语法高亮** +```bash +$ cat existing.txt # 绿色,文件存在 +$ cat missing.txt # 红色,文件不存在 +``` + +## 三、我们当前实现的差距 + +### ❌ 缺失的核心功能 + +1. **双击Tab显示所有选项** + - 当前:第一次Tab就显示菜单 + - 应该:第一次Tab尝试补全,第二次显示选项 + +2. **公共前缀补全** + - 当前:直接显示菜单 + - 应该:先补全到公共前缀 + +3. **无需显式触发** + - 当前:必须Tab才触发 + - 应该:输入时就准备好建议 + +4. **连续路径补全** + - 当前:补全后停止 + - 应该:目录补全后继续等待下一次Tab + +5. **通配符展开** + - 当前:不支持 + - 应该:*.js展开为所有js文件 + +### ✅ 已有但需优化 + +1. **上下文检测** + - 有基础实现,但不够智能 + +2. **文件类型区分** + - 有图标,但行为未区分 + +3. **即时响应** + - 已实现,但交互模式不对 + +## 四、理想的Tab补全交互流程 + +### 阶段1:输入时(无Tab) +``` +用户输入: cat pa +内部状态: 准备suggestions ["package.json", "package-lock.json"] +显示: 无变化(或灰色提示) +``` + +### 阶段2:第一次Tab +``` +用户操作: [Tab] +判断逻辑: + - 唯一匹配 → 直接补全 + - 多个匹配但有公共前缀 → 补全到公共前缀 + - 多个匹配无公共前缀 → 蜂鸣/无反应 +``` + +### 阶段3:第二次Tab +``` +用户操作: [Tab][Tab] +行为: 显示所有可能的补全选项 +格式: 水平排列,按列对齐 +``` + +### 阶段4:选择 +``` +继续输入: 缩小范围 +方向键: 选择(可选) +Tab: 循环选择(可选) +Enter: 确认选择 +``` + +## 五、改进建议 + +### 必须实现(核心体验) +1. **双Tab机制** - 第一次补全,第二次显示 +2. **公共前缀** - 智能补全到最长公共前缀 +3. **连续补全** - 目录后继续补全 +4. **更智能的上下文** - 命令感知 + +### 应该实现(提升体验) +1. **灰色建议** - 输入时显示 +2. **历史感知** - 基于使用频率排序 +3. **模糊匹配** - 支持简写 +4. **路径缓存** - 提升性能 + +### 可以实现(锦上添花) +1. **语法高亮** - 文件存在性 +2. **自定义补全** - 用户定义规则 +3. **异步加载** - 大目录优化 +4. **补全预览** - 显示文件内容预览 + +## 六、技术实现要点 + +### Tab计数器 +```typescript +interface TabState { + lastTabTime: number + tabCount: number + lastContext: string +} + +// 双击检测:300ms内的第二次Tab +if (Date.now() - lastTabTime < 300) { + tabCount++ +} else { + tabCount = 1 +} +``` + +### 公共前缀算法 +```typescript +function findCommonPrefix(strings: string[]): string { + if (!strings.length) return '' + return strings.reduce((prefix, str) => { + while (!str.startsWith(prefix)) { + prefix = prefix.slice(0, -1) + } + return prefix + }) +} +``` + +### 智能补全决策 +```typescript +function handleTab(suggestions: string[]): Action { + if (suggestions.length === 0) { + return 'beep' + } + if (suggestions.length === 1) { + return 'complete' + } + const prefix = findCommonPrefix(suggestions) + if (prefix.length > currentWord.length) { + return 'complete-to-prefix' + } + if (isSecondTab()) { + return 'show-menu' + } + return 'beep' +} +``` + +## 七、优先级路线图 + +### Phase 1: 核心终端行为(必须) +- [ ] 双Tab机制 +- [ ] 公共前缀补全 +- [ ] 连续路径补全 +- [ ] 更准确的上下文检测 + +### Phase 2: 现代增强(应该) +- [ ] 实时灰色建议 +- [ ] 历史/频率排序 +- [ ] 模糊匹配支持 + +### Phase 3: 高级功能(可选) +- [ ] 通配符展开 +- [ ] 自定义补全规则 +- [ ] 异步性能优化 \ No newline at end of file diff --git a/docs/TERMINAL_TAB_TEST.md b/docs/TERMINAL_TAB_TEST.md new file mode 100644 index 0000000..b96dbc1 --- /dev/null +++ b/docs/TERMINAL_TAB_TEST.md @@ -0,0 +1,136 @@ +# 终端Tab补全测试用例 + +## ✅ 测试环境准备 + +```bash +# 创建测试文件 +echo "test" > package.json +echo "test" > package-lock.json +echo "test" > README.md +echo "test" > README.txt +mkdir -p src/components src/hooks src/utils +``` + +## 📝 测试场景 + +### Test 1: 公共前缀补全 +```bash +输入: cat p[Tab] +期望: cat package # 补全到公共前缀 +输入: cat package[Tab] +期望: (无反应/蜂鸣) # 无法继续补全 +输入: cat package[Tab][Tab] +期望: 显示菜单: + 📄 package.json + 📄 package-lock.json +``` + +### Test 2: 唯一匹配 +```bash +输入: cat RE[Tab] +期望: cat README # 补全到公共前缀 +输入: cat README.[Tab] +期望: (无反应) # 仍有歧义 +输入: cat README.m[Tab] +期望: cat README.md # 唯一匹配,直接完成 +``` + +### Test 3: 目录补全 +```bash +输入: cd s[Tab] +期望: cd src/ # 唯一匹配,加斜杠 +输入: cd src/[Tab] +期望: (无反应) # 多个选项 +输入: cd src/[Tab][Tab] +期望: 显示菜单: + 📁 components/ + 📁 hooks/ + 📁 utils/ +``` + +### Test 4: 命令补全 +```bash +输入: /he[Tab] +期望: /help # 唯一匹配 +输入: /[Tab] +期望: (无反应) # 需要双Tab +输入: /[Tab][Tab] +期望: 显示所有命令 +``` + +### Test 5: Agent补全 +```bash +输入: @agent-c[Tab] +期望: @agent-code-writer # 如果唯一 +或 +期望: @agent-c # 补全公共前缀 +输入: @agent-c[Tab][Tab] +期望: 显示匹配的agents +``` + +### Test 6: 连续补全 +```bash +输入: cd src/c[Tab] +期望: cd src/components/ # 补全 +输入: 继续输入 [Tab] +期望: 可以继续补全下一级 +``` + +## 🔍 验证要点 + +### 核心行为 +- [ ] 第一次Tab尝试补全,不显示菜单 +- [ ] 第二次Tab才显示菜单 +- [ ] 公共前缀自动补全 +- [ ] 唯一匹配立即完成 +- [ ] 无匹配时不响应(蜂鸣) + +### 状态管理 +- [ ] Tab状态在500ms后重置 +- [ ] 输入改变时重置状态 +- [ ] 菜单显示后Tab选择项目 + +### 边界情况 +- [ ] 空前缀需要双Tab +- [ ] 文件名包含空格的处理 +- [ ] 路径中的特殊字符 + +## 🎯 预期改进 + +### Before (现在的问题) +``` +cat p[Tab] +▸ package.json # 立即显示菜单 ❌ + package-lock.json +``` + +### After (改进后) +``` +cat p[Tab] +cat package # 补全公共前缀 ✅ +cat package[Tab][Tab] +package.json package-lock.json # 双Tab显示 ✅ +``` + +## 📊 行为对比表 + +| 场景 | Bash/Zsh | 旧实现 | 新实现 | +|------|----------|--------|--------| +| 多个匹配+Tab | 补全公共前缀 | 显示菜单 | 补全公共前缀 ✅ | +| 多个匹配+Tab+Tab | 显示选项 | N/A | 显示选项 ✅ | +| 唯一匹配+Tab | 立即完成 | 立即完成 | 立即完成 ✅ | +| 无匹配+Tab | 蜂鸣 | 无反应 | 蜂鸣 ✅ | +| 目录补全 | 加斜杠 | 加斜杠 | 加斜杠 ✅ | + +## 🚀 性能指标 + +- Tab响应时间: <50ms +- 双Tab检测窗口: 500ms +- 公共前缀计算: O(n*m) 其中n=建议数,m=平均长度 + +## 📝 用户体验提升 + +1. **减少操作次数**: 公共前缀补全减少输入 +2. **符合肌肉记忆**: 与终端行为100%一致 +3. **渐进式显示**: 只在需要时显示菜单 +4. **智能判断**: 根据上下文做最优选择 \ No newline at end of file diff --git a/docs/TERMINAL_VS_CURRENT.md b/docs/TERMINAL_VS_CURRENT.md new file mode 100644 index 0000000..6f51431 --- /dev/null +++ b/docs/TERMINAL_VS_CURRENT.md @@ -0,0 +1,186 @@ +# 终端行为对比:标准 Terminal vs 我们的实现 + +## 🔴 关键差异对比 + +### 场景1:多个匹配文件 +```bash +# 文件列表:package.json, package-lock.json, public/ + +# ✅ 真正的终端(bash/zsh) +$ cat p[Tab] +$ cat p█ # 第一次Tab:没反应(或蜂鸣) +$ cat p[Tab][Tab] +package.json package-lock.json public/ # 第二次Tab:显示选项 +$ cat pa[Tab] +$ cat package█ # 补全到公共前缀 "package" +$ cat package[Tab][Tab] +package.json package-lock.json # 再显示剩余选项 + +# ❌ 我们当前的实现 +$ cat p[Tab] +▸ 📄 package.json # 立即显示菜单 + 📄 package-lock.json + 📁 public/ +``` + +### 场景2:唯一匹配 +```bash +# ✅ 真正的终端 +$ cat READ[Tab] +$ cat README.md█ # 立即补全,一步到位 + +# ✅ 我们的实现(这个是对的!) +$ cat READ[Tab] +$ cat README.md█ # 也是立即补全 +``` + +### 场景3:目录补全后继续 +```bash +# ✅ 真正的终端 +$ cd src[Tab] +$ cd src/█ # 补全并加斜杠,光标在斜杠后 +$ cd src/[Tab] # 可以继续Tab +components/ hooks/ utils/ # 显示src/下的内容 + +# ❌ 我们的实现 +$ cd src[Tab] +$ cd src/█ # 补全后结束 +$ cd src/[Tab] # 需要重新检测上下文 +``` + +### 场景4:命令补全 +```bash +# ✅ 真正的终端 +$ git a[Tab] +$ git add█ # 唯一匹配,直接补全 + +$ git [Tab][Tab] # 空前缀,需要双Tab +add commit push pull status # 显示所有git命令 + +# ⚠️ 我们的实现 +$ /he[Tab] +$ /help█ # 命令补全工作正常 +$ /[Tab] # 但立即显示菜单(应该需要双Tab) +▸ help + model + agents +``` + +## 📊 行为差异总结 + +| 特性 | 标准终端 | 我们的实现 | 影响 | +|------|----------|------------|------| +| **双Tab显示** | ✅ 需要按两次 | ❌ 第一次就显示 | 打断输入流程 | +| **公共前缀** | ✅ 智能补全 | ❌ 直接显示菜单 | 多余的选择步骤 | +| **连续补全** | ✅ 自然流畅 | ❌ 需要重新触发 | 效率降低 | +| **空前缀Tab** | ✅ 需要双击 | ❌ 立即显示 | 意外触发 | +| **蜂鸣反馈** | ✅ 无匹配时蜂鸣 | ❌ 静默 | 缺少反馈 | + +## 🎯 核心问题 + +### 1. **Tab计数缺失** +```typescript +// 我们现在的代码(错误) +if (key.tab) { + showSuggestions() // 立即显示 +} + +// 应该是 +if (key.tab) { + if (isSecondTab()) { + showSuggestions() + } else { + tryComplete() + } +} +``` + +### 2. **公共前缀算法缺失** +```typescript +// 需要添加 +function getCommonPrefix(items: string[]): string { + if (items.length === 0) return '' + if (items.length === 1) return items[0] + + let prefix = items[0] + for (let i = 1; i < items.length; i++) { + while (!items[i].startsWith(prefix)) { + prefix = prefix.slice(0, -1) + } + } + return prefix +} +``` + +### 3. **状态管理不足** +```typescript +// 当前:无状态 +// 需要: +interface CompletionState { + lastTabTime: number + tabCount: number + lastPrefix: string + lastSuggestions: string[] + isShowingMenu: boolean +} +``` + +## 💡 为什么这些差异重要? + +### 用户期望 +- **肌肉记忆**:数十年的终端使用习惯 +- **效率优先**:最少的按键完成任务 +- **预测性**:行为必须可预测 + +### 实际影响 +1. **输入中断**:过早显示菜单打断思路 +2. **额外操作**:本可以自动补全的需要手动选择 +3. **认知负担**:行为不一致增加心智负担 + +## 🔧 改进优先级 + +### 🔴 必须修复(破坏体验) +1. **双Tab机制** - 这是最基础的终端行为 +2. **公共前缀补全** - 减少不必要的选择 +3. **连续补全** - 路径导航的基础 + +### 🟡 应该改进(提升体验) +1. **蜂鸣反馈** - 告诉用户发生了什么 +2. **灰色提示** - 现代终端的标配 +3. **历史感知** - 更智能的排序 + +### 🟢 可以添加(锦上添花) +1. **模糊匹配** - 容错输入 +2. **预览功能** - 显示文件内容 +3. **自定义规则** - 用户定制 + +## 📝 用户故事 + +### 当前体验 😤 +``` +我:想输入 "cat package.json" +我:输入 "cat p" + Tab +系统:弹出菜单让我选择 +我:需要按方向键选择 +我:按Enter确认 +结果:5个操作才完成 +``` + +### 理想体验 😊 +``` +我:想输入 "cat package.json" +我:输入 "cat pa" + Tab +系统:自动补全到 "cat package" +我:输入 ".j" + Tab +系统:补全到 "cat package.json" +结果:3个操作完成 +``` + +## 🎬 下一步 + +最小可行改进(MVP): +1. 实现Tab计数器 +2. 添加公共前缀补全 +3. 修复连续补全 + +这三个改动就能让体验提升80%! \ No newline at end of file diff --git a/docs/agents-system.md b/docs/agents-system.md new file mode 100644 index 0000000..c69f81c --- /dev/null +++ b/docs/agents-system.md @@ -0,0 +1,230 @@ +# Agent Configuration System + +## Overview + +Kode's Agent system allows you to create specialized AI agents with predefined configurations, tools, and prompts. This enables more efficient task execution by using purpose-built agents for specific types of work. + +**New in this version**: Use `@run-agent-name` for intelligent delegation with auto-completion support. + +## Features + +- **Dynamic Agent Loading**: Agents are loaded from configuration files at runtime +- **Five-tier Priority System**: Built-in < .claude (user) < .kode (user) < .claude (project) < .kode (project) +- **Hot Reload**: Agent files monitored via Node.js fs.watch with cache invalidation +- **Tool Restrictions**: Limit agents to specific tools for security and focus +- **Model Selection**: Each agent can specify its preferred AI model +- **Interactive Management**: Use `/agents` command for graphical management + +## Quick Start + +### Using Pre-configured Agents + +Kode has one built-in agent: + +```bash +@run-agent-general-purpose Find all TypeScript test files +@run-agent-my-custom-agent Implement a new user authentication feature +``` + +### Managing Agents + +Use the `/agents` command in the Kode REPL to manage agents: + +```bash +kode +> /agents +``` + +This opens an interactive UI where you can: +- View all available agents +- Create new agents +- Edit existing agents +- Delete custom agents + +Keyboard shortcuts: +- `c` - Create new agent +- `r` - Reload agents +- `d` - Delete selected agent (when viewing) +- `q` or `Esc` - Exit + +## Agent Configuration + +### File Structure + +Agents are defined as Markdown files with YAML frontmatter: + +```markdown +--- +name: agent-name +description: "When to use this agent" +tools: ["Tool1", "Tool2", "Tool3"] # or "*" for all tools +model_name: model-name # optional (preferred over deprecated 'model' field) +--- + +System prompt content goes here... +``` + +**Note**: Use `model_name` to specify the AI model. The deprecated `model` field is ignored. + +### Configuration Locations + +Agents can be defined at five levels with priority order (later overrides earlier): + +1. **Built-in** (lowest priority) + - Provided with Kode + - Cannot be modified + +2. **Claude User** (`~/.claude/agents/`) + - Claude Code compatible user-level agents + - Personal agents available across all projects + +3. **Kode User** (`~/.kode/agents/`) + - Kode-specific user-level agents + - Overrides Claude user agents with same name + - Create with `/agents` command or manually + +4. **Claude Project** (`./.claude/agents/`) + - Claude Code compatible project-specific agents + - Overrides user-level agents + +5. **Kode Project** (`./.kode/agents/`) + - Kode-specific project agents + - Highest priority, overrides all others + +### Example: Creating a Custom Agent + +#### 1. Manual Creation + +Create a file `~/.kode/agents/api-designer.md`: + +```markdown +--- +name: api-designer +description: "Designs RESTful APIs and GraphQL schemas with best practices" +tools: ["FileRead", "FileWrite", "Grep"] +model_name: reasoning +--- + +You are an API design specialist. Your expertise includes: + +- Designing RESTful APIs following OpenAPI specifications +- Creating GraphQL schemas with efficient resolvers +- Implementing proper authentication and authorization +- Ensuring API versioning and backward compatibility +- Writing comprehensive API documentation + +Design principles: +- Follow REST best practices (proper HTTP verbs, status codes, etc.) +- Design for scalability and performance +- Include proper error handling and validation +- Consider rate limiting and caching strategies +- Maintain consistency across endpoints +``` + +#### 2. Using /agents Command + +1. Run `kode` to start the REPL +2. Type `/agents` +3. Press `c` to create +4. Follow the prompts: + - Enter agent name + - Describe when to use it + - Specify allowed tools + - Optionally specify a model + - Write the system prompt + +## Advanced Usage + +### Tool Restrictions + +```yaml +tools: ["FileRead", "Grep", "Glob"] # Specific tools +tools: ["*"] # All tools (default) +``` + +### Model Selection + +Specify which AI model the agent should use: + +```yaml +model_name: quick # Fast responses for simple tasks +model_name: main # Default model for general tasks +model_name: reasoning # Complex analysis and design +``` + + +## Available Built-in Agents + +### general-purpose +- **Use for**: General research, complex multi-step tasks +- **Tools**: All tools +- **Model**: task (default) + +Note: This is currently the only built-in agent. Create custom agents using the `/agents` command or by adding configuration files. + +## Custom Agents + +Create your own agents in the appropriate directory: + +```bash +mkdir -p .kode/agents # Project-specific +mkdir -p ~/.kode/agents # User-wide +``` + +## Best Practices + +1. **Agent Naming**: Use descriptive, action-oriented names (e.g., `test-writer`, `api-designer`) + +2. **Tool Selection**: Only include tools the agent actually needs + +3. **System Prompts**: Be specific about the agent's role and guidelines + +4. **Model Choice**: + - Use `quick` for simple, fast operations + - Use `main` for general coding tasks + - Use `reasoning` for complex analysis + +5. **Organization**: + - Keep user agents for personal workflows + - Keep project agents for team-shared configurations + +## Troubleshooting + +### Agents not loading? +- Check file permissions in `~/.kode/agents/` or `./.kode/agents/` +- Ensure YAML frontmatter is valid +- Use `/agents` command and press `r` to reload + +### Agent not working as expected? +- Verify the tools list includes necessary tools +- Check the system prompt is clear and specific +- Test with verbose mode to see actual prompts + +### Hot reload not working? +- File watcher requires proper file system events +- Try manual reload with `/agents` then `r` +- Restart Kode if needed + +## Integration with Task Tool + +The agent system is integrated with Kode's Task tool: + +```typescript +// In your code or scripts +await TaskTool.call({ + description: "Search for patterns", + prompt: "Find all instances of TODO comments", + subagent_type: "general-purpose" +}) +``` + +This allows programmatic use of agents in automation and scripts. + +## Future Enhancements + +Planned improvements: +- Agent templates and inheritance +- Performance metrics per agent +- Agent composition (agents using other agents) +- Cloud-based agent sharing +- Agent versioning and rollback \ No newline at end of file diff --git a/docs/develop-zh/architecture.md b/docs/develop-zh/architecture.md index 3820b0c..ec92ab9 100644 --- a/docs/develop-zh/architecture.md +++ b/docs/develop-zh/architecture.md @@ -109,7 +109,7 @@ cli.tsx (入口点) ``` 用户提示 ↓ -上下文注入 (KODE.md, git 状态等) +上下文注入 (AGENTS.md, git 状态等) ↓ 模型选择 (基于上下文大小) ↓ @@ -218,7 +218,7 @@ interface Tool { ### 5. 上下文管理 自动上下文注入: -- 项目文件 (KODE.md, CLAUDE.md) +- 项目文件 (AGENTS.md, CLAUDE.md) - Git 状态和最近的提交 - 目录结构 - 先前的对话历史 diff --git a/docs/develop-zh/configuration.md b/docs/develop-zh/configuration.md index 1f246dc..64b508b 100644 --- a/docs/develop-zh/configuration.md +++ b/docs/develop-zh/configuration.md @@ -470,11 +470,8 @@ function loadConfig(path: string): Config { ### 调试命令 ```bash -# 显示有效配置 -kode config list --effective - -# 验证配置 -kode config validate +# 显示配置 +kode config list # 重置为默认值 kode config reset diff --git a/docs/develop-zh/overview.md b/docs/develop-zh/overview.md index 50a2e6e..180910a 100644 --- a/docs/develop-zh/overview.md +++ b/docs/develop-zh/overview.md @@ -26,7 +26,7 @@ Kode 中的所有内容都被抽象为"工具" - 一个自包含的功能单元 AI 自动通过以下方式理解您的项目: - Git 状态和最近的提交 - 目录结构分析 -- KODE.md 和 CLAUDE.md 项目文档 +- AGENTS.md 和 CLAUDE.md 项目文档 - .claude/commands/ 和 .kode/commands/ 中的自定义命令定义 - 先前的对话历史和分叉对话 diff --git a/docs/develop-zh/security-model.md b/docs/develop-zh/security-model.md index 9b718b1..34a7c02 100644 --- a/docs/develop-zh/security-model.md +++ b/docs/develop-zh/security-model.md @@ -379,9 +379,6 @@ interface PermissionRequest { 2. **调查** ```bash - # 审查审计日志 - kode security audit --last 1h - # 检查修改的文件 git status git diff diff --git a/docs/develop/architecture.md b/docs/develop/architecture.md index ff6edd0..d2d873a 100644 --- a/docs/develop/architecture.md +++ b/docs/develop/architecture.md @@ -109,7 +109,7 @@ cli.tsx (Entry Point) ``` User Prompt ↓ -Context Injection (KODE.md, git status, etc.) +Context Injection (AGENTS.md, git status, etc.) ↓ Model Selection (based on context size) ↓ @@ -218,7 +218,7 @@ Multi-level permission system: ### 5. Context Management Automatic context injection: -- Project files (KODE.md, CLAUDE.md) +- Project files (AGENTS.md, CLAUDE.md) - Git status and recent commits - Directory structure - Previous conversation history diff --git a/docs/develop/configuration.md b/docs/develop/configuration.md index c206057..0e5b9e5 100644 --- a/docs/develop/configuration.md +++ b/docs/develop/configuration.md @@ -470,11 +470,8 @@ Temporary for current session: ### Debug Commands ```bash -# Show effective configuration -kode config list --effective - -# Validate configuration -kode config validate +# Show configuration +kode config list # Reset to defaults kode config reset diff --git a/docs/develop/modules/context-system.md b/docs/develop/modules/context-system.md index 5daa4b8..d85068e 100644 --- a/docs/develop/modules/context-system.md +++ b/docs/develop/modules/context-system.md @@ -57,7 +57,7 @@ interface CompleteContext { dependencies?: Dependencies // Documentation - contextFile?: string // KODE.md content + contextFile?: string // AGENTS.md content claudeFile?: string // CLAUDE.md content readmeContent?: string // README.md content @@ -192,15 +192,15 @@ class ProjectAnalyzer { ## Context Files -### KODE.md +### AGENTS.md ```typescript class ContextFileLoader { private readonly CONTEXT_PATHS = [ - 'KODE.md', - '.claude/KODE.md', - 'docs/KODE.md', - '.github/KODE.md' + 'AGENTS.md', + '.claude/AGENTS.md', + 'docs/AGENTS.md', + '.github/AGENTS.md' ] async loadContextFile(): Promise { diff --git a/docs/develop/overview.md b/docs/develop/overview.md index 84ec872..c2997c6 100644 --- a/docs/develop/overview.md +++ b/docs/develop/overview.md @@ -26,7 +26,7 @@ Unlike web-based AI assistants, Kode is built specifically for terminal workflow The AI automatically understands your project through: - Git status and recent commits - Directory structure analysis -- KODE.md and CLAUDE.md project documentation +- AGENTS.md and CLAUDE.md project documentation - Custom command definitions in .claude/commands/ and .kode/commands/ - Previous conversation history and forked conversations diff --git a/docs/develop/security-model.md b/docs/develop/security-model.md index aad0909..8704528 100644 --- a/docs/develop/security-model.md +++ b/docs/develop/security-model.md @@ -379,9 +379,6 @@ interface PermissionRequest { 2. **Investigation** ```bash - # Review audit logs - kode security audit --last 1h - # Check modified files git status git diff diff --git a/docs/intelligent-completion.md b/docs/intelligent-completion.md new file mode 100644 index 0000000..97e1e84 --- /dev/null +++ b/docs/intelligent-completion.md @@ -0,0 +1,166 @@ +# Intelligent Completion System + +## Overview + +Kode features a state-of-the-art intelligent completion system that revolutionizes terminal interaction with AI agents and commands. The system uses advanced fuzzy matching algorithms inspired by Chinese input methods, modern IDEs, and terminal fuzzy finders. + +## Key Features + +### 1. Advanced Fuzzy Matching Algorithm + +Our custom `advancedFuzzyMatcher` combines multiple matching strategies: + +- **Exact Prefix Matching** - Highest priority for exact starts +- **Hyphen-Aware Matching** - Treats hyphens as optional word boundaries +- **Word Boundary Detection** - Matches characters at word starts +- **Abbreviation Matching** - Supports shortcuts like `dq` → `dao-qi` +- **Numeric Suffix Handling** - Intelligently matches `py3` → `python3` +- **Subsequence Matching** - Characters appear in order +- **Fuzzy Segment Matching** - Flexible segment matching + +### 2. Smart Context Detection + +The system automatically detects context without requiring special prefixes: + +```bash +# Type without @, system adds it automatically +gp5 → @ask-gpt-5 +daoqi → @run-agent-dao-qi-harmony-designer +py3 → python3 + +# Tab key fills the match with appropriate prefix +# Enter key completes and adds space +``` + +### 3. Unix Command Intelligence + +#### Common Commands Database +- 500+ curated common Unix/Linux commands +- Categories: File operations, text processing, development tools, network utilities, etc. +- Smart intersection with system PATH - only shows commands that actually exist + +#### Priority Scoring +Commands are ranked by: +1. Match quality score +2. Common usage frequency +3. Position in command database + +### 4. Multi-Source Completion + +The system seamlessly combines completions from: +- **Slash Commands** (`/help`, `/model`, etc.) +- **Agent Mentions** (`@run-agent-*`) +- **Model Consultations** (`@ask-*`) +- **Unix Commands** (from system PATH) +- **File Paths** (directories and files) + +## Architecture + +### Core Components + +``` +src/ +├── utils/ +│ ├── advancedFuzzyMatcher.ts # Advanced matching algorithms +│ ├── fuzzyMatcher.ts # Original matcher (facade) +│ └── commonUnixCommands.ts # Unix command database +└── hooks/ + └── useUnifiedCompletion.ts # Main completion hook +``` + +### Algorithm Details + +#### Hyphen-Aware Matching +```typescript +// Handles: dao → dao-qi-harmony-designer +// Split by hyphens and match flexibly +const words = text.split('-') +// Check concatenated version (ignoring hyphens) +const concatenated = words.join('') +``` + +#### Numeric Suffix Matching +```typescript +// Handles: py3 → python3 +const patternMatch = pattern.match(/^(.+?)(\d+)$/) +if (text.endsWith(suffix) && textWithoutSuffix.startsWith(prefix)) { + // High score for numeric suffix match +} +``` + +#### Word Boundary Matching +```typescript +// Handles: dq → dao-qi +// Match characters at word boundaries +for (const word of words) { + if (word[0] === pattern[patternIdx]) { + score += 50 // Bonus for word boundary + } +} +``` + +## Usage Examples + +### Basic Fuzzy Matching +```bash +# Abbreviations +nde → node +np → npm +dk → docker + +# Partial matches +kub → kubectl +vim → vim, nvim + +# Numeric patterns +py3 → python3 +n18 → node18 +``` + +### Agent/Model Matching +```bash +# Without @ prefix (auto-added on completion) +gp5 → @ask-gpt-5 +claude → @ask-claude-sonnet-4 +dao → @run-agent-dao-qi-harmony-designer +daoqi → @run-agent-dao-qi-harmony-designer +``` + +### Smart Prioritization +```bash +# Input: "doc" +1. docker (common command, high priority) +2. document (if exists) +3. doctor (if exists) + +# Input: "g" +1. git (most common) +2. grep (common) +3. go (if installed) +``` + +## Configuration + +### Minimum Score Threshold +The system uses a minimum score of 5 (very low) to allow flexible matching while filtering noise. + +### Match Ranking +Results are sorted by: +1. Match algorithm score +2. Command priority (for Unix commands) +3. Type priority (agents/models > files > commands) + +## Performance + +- **Sub-millisecond matching** - Optimized algorithms for instant feedback +- **Lazy loading** - Commands loaded on first use +- **Smart caching** - Results cached per session +- **Efficient filtering** - Early termination for obvious non-matches + +## Future Improvements + +- [ ] Learning from user selections +- [ ] Project-specific command priorities +- [ ] Custom abbreviation definitions +- [ ] Typo correction with edit distance +- [ ] Context-aware suggestions based on recent commands \ No newline at end of file diff --git a/docs/mention-system.md b/docs/mention-system.md new file mode 100644 index 0000000..e6b5ca4 --- /dev/null +++ b/docs/mention-system.md @@ -0,0 +1,222 @@ +# @ Mention System + +## Overview + +Kode's @ mention system provides intelligent auto-completion and smart delegation for models, agents, and files. This unified interface makes it easy to reference different resources and trigger appropriate actions. + +## Features + +- 🦜 **Expert Model Consultation** - `@ask-model-name` +- 👤 **Agent Delegation** - `@run-agent-name` +- 📁 **File References** - `@path/to/file` +- ⚡ **Smart Completion** - Real-time suggestions as you type +- 🔍 **Context-Aware** - Shows relevant options based on input + +## Mention Types + +### 🦜 Expert Model Consultation (`@ask-model-name`) + +Consult specific AI models for specialized analysis and expert opinions. + +**Format**: `@ask-{model-name}` + +**Examples**: +```bash +@ask-claude-sonnet-4 How should I optimize this React component? +@ask-gpt-5 What are the security implications of this API design? +@ask-o1-preview Analyze the time complexity of this algorithm +``` + +**Behavior**: +- Triggers `AskExpertModelTool` +- Model receives only your question (no conversation history) +- Requires complete, self-contained questions +- Ideal for getting fresh perspectives from different models + +### 👤 Agent Delegation (`@run-agent-name`) + +Delegate tasks to specialized subagents with predefined capabilities. + +**Format**: `@run-agent-{agent-type}` + +**Examples**: +```bash +@run-agent-general-purpose Review this code for over-engineering +@run-agent-my-custom-agent Design a microservices architecture +``` + +**Behavior**: +- Triggers `TaskTool` with specified subagent +- Agent has access to project context and tools +- Uses agent's specialized prompt and model preferences +- Ideal for focused, expert-level task execution + +### 📁 File References (`@path/to/file`) + +Reference files and directories with intelligent path completion. + +**Format**: `@{file-path}` + +**Examples**: +```bash +@src/components/Button.tsx +@docs/api-reference.md +@package.json +@README.md +``` + +**Behavior**: +- Shows file/directory structure as you type +- Supports relative and absolute paths +- Integrates with file reading tools +- Provides context for file-based discussions + +## Smart Completion UI + +### Completion Priority + +1. **🦜 Ask Models** (Score: 90) - Expert consultation options +2. **👤 Run Agents** (Score: 85) - Available subagents +3. **📁 Files** (Score: 70-80) - Project files and directories + +### Keyboard Navigation + +- **Tab** - Cycle through suggestions or complete partial matches +- **↑/↓** - Navigate suggestion list +- **Enter** - Select highlighted suggestion +- **Esc** - Close completion menu +- **Space** - Complete and continue (for directories) + +### Visual Indicators + +- 🦜 - Expert model consultation +- 👤 - Agent delegation +- 📁 - Directory +- 📄 - File + +## Implementation Details + +### Mention Processing Pipeline + +1. **Pattern Matching** - Regular expressions detect @ask-, @run-agent-, and @file patterns +2. **Event Emission** - MentionProcessor emits events to SystemReminder service +3. **System Reminder Generation** - Creates tool-specific guidance messages +4. **Tool Invocation** - AI selects appropriate tool based on reminder context + +### Supported Patterns + +```typescript +// Recognized patterns +/@(ask-[\w\-]+)/g // @ask-model-name +/@(run-agent-[\w\-]+)/g // @run-agent-name +/@(agent-[\w\-]+)/g // @agent-name (legacy) +/@([a-zA-Z0-9/._-]+)/g // @file/path +``` + +### Email Protection + +The system intelligently detects email addresses and treats them as regular text: +```bash +user@domain.com # Treated as regular text, no completion +@ask-claude # Triggers completion +``` + +## Legacy Support + +### Legacy Support + +- `@agent-name` format supported by agentMentionDetector +- `@run-agent-name` format supported by mentionProcessor +- Both patterns trigger TaskTool with subagent_type parameter + +### Migration Guide + +```bash +# Old format (still works) +@my-agent + +# New format (recommended) +@run-agent-my-agent +``` + +## Configuration + +### Available Models + +Models are loaded dynamically from your configuration: +```bash +# View configured models +/model + +# Models appear in @ask- completions automatically +``` + +### Available Agents + +Agents are loaded from multiple sources: +- Built-in agents (only general-purpose currently available) +- User agents (`~/.kode/agents/`) +- Project agents (`./.kode/agents/`) + +```bash +# View available agents +/agents + +# Create new agent +/agents -> c (create) +``` + +## Best Practices + +### For Expert Model Consultation + +1. **Provide Complete Context**: Include all relevant background information +2. **Structure Questions**: Background → Situation → Question +3. **Be Specific**: Ask for particular types of analysis or perspectives +4. **Use Right Model**: Choose models based on their strengths + +### For Agent Delegation + +1. **Match Task to Agent**: Use specialists for their expertise areas +2. **Clear Instructions**: Provide specific, actionable task descriptions +3. **Context Awareness**: Agents have project context, use it effectively +4. **Tool Permissions**: Ensure agents have necessary tool access + +### For File References + +1. **Use Auto-completion**: Let the system suggest valid paths +2. **Relative Paths**: Prefer relative paths for project portability +3. **Context Clarity**: Explain what you want to do with the file +4. **Multiple Files**: Reference multiple files when needed + +## Troubleshooting + +### Completion Not Working? + +- Check if you're in the terminal input area +- Ensure @ is at the start of a word boundary +- Try typing more characters to trigger completion +- Restart Kode if completion seems stuck + +### Models/Agents Not Appearing? + +- Verify model configuration with `/model` +- Check agent configurations with `/agents` +- Ensure proper file permissions for agent directories +- Try reloading agents with `/agents` → `r` + +### Wrong Tool Being Selected? + +- Check system reminder events in verbose mode +- Verify mention format matches expected patterns +- Ensure agent configurations are valid +- Review tool descriptions for conflicts + +## Future Enhancements + +Planned improvements: +- **Fuzzy Matching** - Better completion matching +- **Context Hints** - Show tool descriptions in completions +- **Custom Shortcuts** - User-defined @ shortcuts +- **Completion Analytics** - Track most-used mentions +- **Multi-file Selection** - Select multiple files at once \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..f4df575 --- /dev/null +++ b/main.js @@ -0,0 +1 @@ +Testing file 2 diff --git a/package.json b/package.json index 609eb5c..7cc7018 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,12 @@ "env-paths": "^3.0.0", "figures": "^6.1.0", "glob": "^11.0.3", + "gray-matter": "^4.0.3", "highlight.js": "^11.11.1", "ink": "^5.2.1", "ink-link": "^4.1.0", + "ink-select-input": "^6.2.0", + "ink-text-input": "^6.0.0", "lodash-es": "^4.17.21", "lru-cache": "^11.1.0", "marked": "^15.0.12", diff --git a/src/Tool.ts b/src/Tool.ts index 684772d..f58920f 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -1,6 +1,9 @@ import { z } from 'zod' import * as React from 'react' +// DEPRECATED: Use domain/tool/Tool.interface.ts for new implementations +// This interface will be maintained for compatibility during transition + export type SetToolJSXFn = (jsx: { jsx: React.ReactNode | null shouldHidePromptInput: boolean diff --git a/src/commands.ts b/src/commands.ts index 3ca6db4..421774b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -22,6 +22,7 @@ import review from './commands/review' import terminalSetup from './commands/terminalSetup' import { Tool, ToolUseContext } from './Tool' import resume from './commands/resume' +import agents from './commands/agents' import { getMCPCommands } from './services/mcpClient' import { loadCustomCommands } from './services/customCommands' import type { MessageParam } from '@anthropic-ai/sdk/resources/index.mjs' @@ -80,6 +81,7 @@ const INTERNAL_ONLY_COMMANDS = [ctx_viz, resume, listen] // Declared as a function so that we don't run this until getCommands is called, // since underlying functions read from config, which can't be read at module initialization time const COMMANDS = memoize((): Command[] => [ + agents, clear, compact, config, diff --git a/src/commands/agents.tsx b/src/commands/agents.tsx new file mode 100644 index 0000000..a3c7675 --- /dev/null +++ b/src/commands/agents.tsx @@ -0,0 +1,3401 @@ +import React, { useState, useEffect, useMemo, useCallback, useReducer, Fragment } from 'react' +import { Box, Text, useInput } from 'ink' +import InkTextInput from 'ink-text-input' +import { getActiveAgents, clearAgentCache } from '../utils/agentLoader' +import { AgentConfig } from '../utils/agentLoader' +import { writeFileSync, unlinkSync, mkdirSync, existsSync, readFileSync, renameSync } from 'fs' +import { join } from 'path' +import * as path from 'path' +import { homedir } from 'os' +import * as os from 'os' +import { getCwd } from '../utils/state' +import { getTheme } from '../utils/theme' +import matter from 'gray-matter' +import { exec, spawn } from 'child_process' +import { promisify } from 'util' +import { watch, FSWatcher } from 'fs' +import { getMCPTools } from '../services/mcpClient' +import { getModelManager } from '../utils/model' +import { randomUUID } from 'crypto' + +const execAsync = promisify(exec) + +// Core constants aligned with Claude Code architecture +const AGENT_LOCATIONS = { + USER: "user", + PROJECT: "project", + BUILT_IN: "built-in", + ALL: "all" +} as const + +const UI_ICONS = { + pointer: "❯", + checkboxOn: "☑", + checkboxOff: "☐", + warning: "⚠", + separator: "─", + loading: "◐◑◒◓" +} as const + +const FOLDER_CONFIG = { + FOLDER_NAME: ".claude", + AGENTS_DIR: "agents" +} as const + +// Tool categories for sophisticated selection +const TOOL_CATEGORIES = { + read: ['Read', 'Glob', 'Grep', 'LS'], + edit: ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'], + execution: ['Bash', 'BashOutput', 'KillBash'], + web: ['WebFetch', 'WebSearch'], + other: ['TodoWrite', 'ExitPlanMode', 'Task'] +} as const + +type AgentLocation = typeof AGENT_LOCATIONS[keyof typeof AGENT_LOCATIONS] + +// Models will be listed dynamically from ModelManager + +// Comprehensive mode state for complete UI flow +type ModeState = { + mode: 'list-agents' | 'create-location' | 'create-method' | 'create-generate' | 'create-type' | + 'create-description' | 'create-tools' | 'create-model' | 'create-color' | 'create-prompt' | 'create-confirm' | + 'agent-menu' | 'view-agent' | 'edit-agent' | 'edit-tools' | 'edit-model' | 'edit-color' | 'delete-confirm' + location?: AgentLocation + selectedAgent?: AgentConfig + previousMode?: ModeState + [key: string]: any +} + +// State for agent creation flow +type CreateState = { + location: AgentLocation | null + agentType: string + method: 'generate' | 'manual' | null + generationPrompt: string + whenToUse: string + selectedTools: string[] + selectedModel: string | null // null for inherit, or model profile modelName + selectedColor: string | null + systemPrompt: string + isGenerating: boolean + wasGenerated: boolean + isAIGenerated: boolean + error: string | null + warnings: string[] + // Cursor positions for text inputs + agentTypeCursor: number + whenToUseCursor: number + promptCursor: number + generationPromptCursor: number +} + +type Tool = { + name: string + description?: string | (() => Promise) +} + +// Map a stored model identifier to a display name via ModelManager +function getDisplayModelName(modelId?: string | null): string { + // null/undefined means inherit from parent (task model) + if (!modelId) return 'Inherit' + + try { + const profiles = getModelManager().getActiveModelProfiles() + const profile = profiles.find((p: any) => p.modelName === modelId || p.name === modelId) + return profile ? profile.name : `Custom (${modelId})` + } catch (error) { + console.warn('Failed to get model profiles:', error) + return modelId ? `Custom (${modelId})` : 'Inherit' + } +} + +// AI Generation response type +type GeneratedAgent = { + identifier: string + whenToUse: string + systemPrompt: string +} + +// AI generation function (use main pointer model) +async function generateAgentWithClaude(prompt: string): Promise { + // Import Claude service dynamically to avoid circular dependencies + const { queryModel } = await import('../services/claude') + + const systemPrompt = `You are an expert at creating AI agent configurations. Based on the user's description, generate a specialized agent configuration. + +Return your response as a JSON object with exactly these fields: +- identifier: A short, kebab-case identifier for the agent (e.g., "code-reviewer", "security-auditor") +- whenToUse: A clear description of when this agent should be used (50-200 words) +- systemPrompt: A comprehensive system prompt that defines the agent's role, capabilities, and behavior (200-500 words) + +Make the agent highly specialized and effective for the described use case.` + + try { + const messages = [ + { + type: 'user', + uuid: randomUUID(), + message: { role: 'user', content: prompt }, + }, + ] as any + const response = await queryModel('main', messages, [systemPrompt]) + + // Get the text content from the response - handle both string and object responses + let responseText = '' + if (typeof response.message?.content === 'string') { + responseText = response.message.content + } else if (Array.isArray(response.message?.content)) { + const textContent = response.message.content.find((c: any) => c.type === 'text') + responseText = textContent?.text || '' + } else if (response.message?.content?.[0]?.text) { + responseText = response.message.content[0].text + } + + if (!responseText) { + throw new Error('No text content in Claude response') + } + + // 安全限制 + const MAX_JSON_SIZE = 100_000 // 100KB + const MAX_FIELD_LENGTH = 10_000 + + if (responseText.length > MAX_JSON_SIZE) { + throw new Error('Response too large') + } + + // 安全的JSON提取和解析 + let parsed: any + try { + // 首先尝试直接解析整个响应 + parsed = JSON.parse(responseText.trim()) + } catch { + // 如果失败,提取第一个JSON对象,限制搜索范围 + const startIdx = responseText.indexOf('{') + const endIdx = responseText.lastIndexOf('}') + + if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) { + throw new Error('No valid JSON found in Claude response') + } + + const jsonStr = responseText.substring(startIdx, endIdx + 1) + if (jsonStr.length > MAX_JSON_SIZE) { + throw new Error('JSON content too large') + } + + try { + parsed = JSON.parse(jsonStr) + } catch (parseError) { + throw new Error(`Invalid JSON format: ${parseError instanceof Error ? parseError.message : 'Unknown error'}`) + } + } + + // 深度验证和安全清理 + const identifier = String(parsed.identifier || '').slice(0, 100).trim() + const whenToUse = String(parsed.whenToUse || '').slice(0, MAX_FIELD_LENGTH).trim() + const agentSystemPrompt = String(parsed.systemPrompt || '').slice(0, MAX_FIELD_LENGTH).trim() + + // 验证必填字段 + if (!identifier || !whenToUse || !agentSystemPrompt) { + throw new Error('Invalid response structure: missing required fields (identifier, whenToUse, systemPrompt)') + } + + // 清理危险字符(控制字符和非打印字符) + const sanitize = (str: string) => str.replace(/[\x00-\x1F\x7F-\x9F]/g, '') + + // 验证identifier格式(只允许字母、数字、连字符) + const cleanIdentifier = sanitize(identifier) + if (!/^[a-zA-Z0-9-]+$/.test(cleanIdentifier)) { + throw new Error('Invalid identifier format: only letters, numbers, and hyphens allowed') + } + + return { + identifier: cleanIdentifier, + whenToUse: sanitize(whenToUse), + systemPrompt: sanitize(agentSystemPrompt) + } + } catch (error) { + console.error('AI generation failed:', error) + // Fallback to a reasonable default based on the prompt + const fallbackId = prompt.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .slice(0, 30) + + return { + identifier: fallbackId || 'custom-agent', + whenToUse: `Use this agent when you need assistance with: ${prompt}`, + systemPrompt: `You are a specialized assistant focused on helping with ${prompt}. Provide expert-level assistance in this domain.` + } + } +} + +// Comprehensive validation system +function validateAgentType(agentType: string, existingAgents: AgentConfig[] = []): { + isValid: boolean + errors: string[] + warnings: string[] +} { + const errors: string[] = [] + const warnings: string[] = [] + + if (!agentType) { + errors.push("Agent type is required") + return { isValid: false, errors, warnings } + } + + if (!/^[a-zA-Z]/.test(agentType)) { + errors.push("Agent type must start with a letter") + } + + if (!/^[a-zA-Z0-9-]+$/.test(agentType)) { + errors.push("Agent type can only contain letters, numbers, and hyphens") + } + + if (agentType.length < 3) { + errors.push("Agent type must be at least 3 characters long") + } + + if (agentType.length > 50) { + errors.push("Agent type must be less than 50 characters") + } + + // Check for reserved names + const reserved = ['help', 'exit', 'quit', 'agents', 'task'] + if (reserved.includes(agentType.toLowerCase())) { + errors.push("This name is reserved") + } + + // Check for duplicates + const duplicate = existingAgents.find(a => a.agentType === agentType) + if (duplicate) { + errors.push(`An agent with this name already exists in ${duplicate.location}`) + } + + // Warnings + if (agentType.includes('--')) { + warnings.push("Consider avoiding consecutive hyphens") + } + + return { + isValid: errors.length === 0, + errors, + warnings + } +} + +function validateAgentConfig(config: Partial, existingAgents: AgentConfig[] = []): { + isValid: boolean + errors: string[] + warnings: string[] +} { + const errors: string[] = [] + const warnings: string[] = [] + + // Validate agent type + if (config.agentType) { + const typeValidation = validateAgentType(config.agentType, existingAgents) + errors.push(...typeValidation.errors) + warnings.push(...typeValidation.warnings) + } + + // Validate description + if (!config.whenToUse) { + errors.push("Description is required") + } else if (config.whenToUse.length < 10) { + warnings.push("Description should be more descriptive (at least 10 characters)") + } + + // Validate system prompt + if (!config.systemPrompt) { + errors.push("System prompt is required") + } else if (config.systemPrompt.length < 20) { + warnings.push("System prompt might be too short for effective agent behavior") + } + + // Validate tools + if (!config.selectedTools || config.selectedTools.length === 0) { + warnings.push("No tools selected - agent will have limited capabilities") + } + + return { + isValid: errors.length === 0, + errors, + warnings + } +} + +// File system operations with Claude Code alignment +function getAgentDirectory(location: AgentLocation): string { + if (location === AGENT_LOCATIONS.BUILT_IN || location === AGENT_LOCATIONS.ALL) { + throw new Error(`Cannot get directory path for ${location} agents`) + } + + if (location === AGENT_LOCATIONS.USER) { + return join(homedir(), FOLDER_CONFIG.FOLDER_NAME, FOLDER_CONFIG.AGENTS_DIR) + } else { + return join(getCwd(), FOLDER_CONFIG.FOLDER_NAME, FOLDER_CONFIG.AGENTS_DIR) + } +} + +function getAgentFilePath(agent: AgentConfig): string { + if (agent.location === 'built-in') { + throw new Error('Cannot get file path for built-in agents') + } + const dir = getAgentDirectory(agent.location as AgentLocation) + return join(dir, `${agent.agentType}.md`) +} + +function ensureDirectoryExists(location: AgentLocation): string { + const dir = getAgentDirectory(location) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + return dir +} + +// Generate agent file content +function generateAgentFileContent( + agentType: string, + description: string, + tools: string[] | '*', + systemPrompt: string, + model?: string, + color?: string +): string { + // Use YAML multi-line string for description to avoid escaping issues + const descriptionLines = description.split('\n') + const formattedDescription = descriptionLines.length > 1 + ? `|\n ${descriptionLines.join('\n ')}` + : JSON.stringify(description) + + const lines = [ + '---', + `name: ${agentType}`, + `description: ${formattedDescription}` + ] + + if (tools) { + if (tools === '*') { + lines.push(`tools: "*"`) + } else if (Array.isArray(tools) && tools.length > 0) { + lines.push(`tools: [${tools.map(t => `"${t}"`).join(', ')}]`) + } + } + + if (model) { + lines.push(`model: ${model}`) + } + + if (color) { + lines.push(`color: ${color}`) + } + + lines.push('---', '', systemPrompt) + return lines.join('\n') +} + +// Save agent to file +async function saveAgent( + location: AgentLocation, + agentType: string, + description: string, + tools: string[], + systemPrompt: string, + model?: string, + color?: string, + throwIfExists: boolean = true +): Promise { + if (location === AGENT_LOCATIONS.BUILT_IN) { + throw new Error('Cannot save built-in agents') + } + + ensureDirectoryExists(location) + + const filePath = join(getAgentDirectory(location), `${agentType}.md`) + const tempFile = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substr(2, 9)}` + + // Ensure tools is properly typed for file saving + const toolsForFile: string[] | '*' = Array.isArray(tools) && tools.length === 1 && tools[0] === '*' ? '*' : tools + const content = generateAgentFileContent(agentType, description, toolsForFile, systemPrompt, model, color) + + try { + // 先写入临时文件,使用 'wx' 确保不覆盖现有文件 + writeFileSync(tempFile, content, { encoding: 'utf-8', flag: 'wx' }) + + // 检查目标文件是否存在(原子性检查) + if (throwIfExists && existsSync(filePath)) { + // 清理临时文件 + try { unlinkSync(tempFile) } catch {} + throw new Error(`Agent file already exists: ${filePath}`) + } + + // 原子性重命名(在大多数文件系统上,rename是原子操作) + renameSync(tempFile, filePath) + + } catch (error) { + // 确保清理临时文件 + try { + if (existsSync(tempFile)) { + unlinkSync(tempFile) + } + } catch (cleanupError) { + console.warn('Failed to cleanup temp file:', cleanupError) + } + throw error + } +} + +// Delete agent file +async function deleteAgent(agent: AgentConfig): Promise { + if (agent.location === 'built-in') { + throw new Error('Cannot delete built-in agents') + } + + const filePath = getAgentFilePath(agent) + unlinkSync(filePath) +} + +// Open file in system editor - 安全版本,防止命令注入 +async function openInEditor(filePath: string): Promise { + // 安全验证:确保路径在允许的目录内 + const resolvedPath = path.resolve(filePath) + const projectDir = process.cwd() + const homeDir = os.homedir() + + if (!resolvedPath.startsWith(projectDir) && !resolvedPath.startsWith(homeDir)) { + throw new Error('Access denied: File path outside allowed directories') + } + + // 验证文件扩展名 + if (!resolvedPath.endsWith('.md')) { + throw new Error('Invalid file type: Only .md files are allowed') + } + + return new Promise((resolve, reject) => { + const platform = process.platform + let command: string + let args: string[] + + // 使用spawn而不是exec,避免shell注入 + switch (platform) { + case 'darwin': // macOS + command = 'open' + args = [resolvedPath] + break + case 'win32': // Windows + command = 'cmd' + args = ['/c', 'start', '', resolvedPath] + break + default: // Linux and others + command = 'xdg-open' + args = [resolvedPath] + break + } + + // 使用spawn替代exec,避免shell解释 + const child = spawn(command, args, { + detached: true, + stdio: 'ignore', + // 确保没有shell解释 + shell: false + }) + + child.unref() // 允许父进程退出 + + child.on('error', (error) => { + reject(new Error(`Failed to open editor: ${error.message}`)) + }) + + child.on('exit', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`Editor exited with code ${code}`)) + } + }) + }) +} + +// Update existing agent +async function updateAgent( + agent: AgentConfig, + description: string, + tools: string[] | '*', + systemPrompt: string, + color?: string, + model?: string +): Promise { + if (agent.location === 'built-in') { + throw new Error('Cannot update built-in agents') + } + + const toolsForFile = tools.length === 1 && tools[0] === '*' ? '*' : tools + const content = generateAgentFileContent(agent.agentType, description, toolsForFile, systemPrompt, model, color) + const filePath = getAgentFilePath(agent) + + writeFileSync(filePath, content, { encoding: 'utf-8', flag: 'w' }) +} + +// Enhanced UI Components with Claude Code alignment + +interface HeaderProps { + title: string + subtitle?: string + step?: number + totalSteps?: number + children?: React.ReactNode +} + +function Header({ title, subtitle, step, totalSteps, children }: HeaderProps) { + const theme = getTheme() + return ( + + {title} + {subtitle && ( + + {step && totalSteps ? `Step ${step}/${totalSteps}: ` : ''}{subtitle} + + )} + {children} + + ) +} + +interface InstructionBarProps { + instructions?: string +} + +function InstructionBar({ instructions = "Press ↑↓ to navigate · Enter to select · Esc to go back" }: InstructionBarProps) { + const theme = getTheme() + return ( + + + {instructions} + + + ) +} + +interface SelectListProps { + options: Array<{ label: string; value: string }> + selectedIndex: number + onChange: (value: string) => void + onCancel?: () => void + numbered?: boolean +} + +function SelectList({ options, selectedIndex, onChange, onCancel, numbered = true }: SelectListProps) { + const theme = getTheme() + + useInput((input, key) => { + if (key.escape && onCancel) { + onCancel() + } else if (key.return) { + onChange(options[selectedIndex].value) + } + }) + + return ( + + {options.map((option, idx) => ( + + + {idx === selectedIndex ? `${UI_ICONS.pointer} ` : " "} + {numbered ? `${idx + 1}. ` : ''}{option.label} + + + ))} + + ) +} + + +// Multiline text input component with better UX +interface MultilineTextInputProps { + value: string + onChange: (value: string) => void + placeholder?: string + onSubmit?: () => void + focus?: boolean + rows?: number + error?: string | null +} + +function MultilineTextInput({ + value, + onChange, + placeholder = '', + onSubmit, + focus = true, + rows = 5, + error +}: MultilineTextInputProps) { + const theme = getTheme() + const [internalValue, setInternalValue] = useState(value) + const [cursorBlink, setCursorBlink] = useState(true) + + // Sync with external value changes + useEffect(() => { + setInternalValue(value) + }, [value]) + + // Cursor blink animation + useEffect(() => { + if (!focus) return + const timer = setInterval(() => { + setCursorBlink(prev => !prev) + }, 500) + return () => clearInterval(timer) + }, [focus]) + + // Calculate display metrics + const lines = internalValue.split('\n') + const lineCount = lines.length + const charCount = internalValue.length + const isEmpty = !internalValue.trim() + const hasContent = !isEmpty + + // Format lines for display with word wrapping + const formatLines = (text: string): string[] => { + if (!text && placeholder) { + return [placeholder] + } + const maxWidth = 70 // Maximum characters per line + const result: string[] = [] + const textLines = text.split('\n') + + textLines.forEach(line => { + if (line.length <= maxWidth) { + result.push(line) + } else { + // Word wrap long lines + let remaining = line + while (remaining.length > 0) { + result.push(remaining.slice(0, maxWidth)) + remaining = remaining.slice(maxWidth) + } + } + }) + + return result.length > 0 ? result : [''] + } + + const displayLines = formatLines(internalValue) + const visibleLines = displayLines.slice(Math.max(0, displayLines.length - rows)) + + // Handle submit + const handleSubmit = () => { + if (internalValue.trim() && onSubmit) { + onSubmit() + } + } + + return ( + + {/* Modern card-style input container */} + + {/* Input area */} + + + {/* Use ink-text-input for better input handling */} + { + setInternalValue(val) + onChange(val) + }} + onSubmit={handleSubmit} + focus={focus} + placeholder={placeholder} + /> + + {/* Show cursor indicator when focused */} + {focus && cursorBlink && hasContent && ( + _ + )} + + + + {/* Status bar */} + + + {hasContent ? ( + + ✓ {charCount} chars • {lineCount} line{lineCount !== 1 ? 's' : ''} + + ) : ( + ○ Type to begin... + )} + + + {error ? ( + ⚠ {error} + ) : ( + + {hasContent ? 'Ready' : 'Waiting'} + + )} + + + + + {/* Instructions */} + + + Press Enter to submit · Shift+Enter for new line + + + + ) +} + +// Loading spinner component +interface LoadingSpinnerProps { + text?: string +} + +function LoadingSpinner({ text }: LoadingSpinnerProps) { + const theme = getTheme() + const [frame, setFrame] = useState(0) + + useEffect(() => { + const interval = setInterval(() => { + setFrame(prev => (prev + 1) % UI_ICONS.loading.length) + }, 100) + return () => clearInterval(interval) + }, []) + + return ( + + {UI_ICONS.loading[frame]} + {text && {text}} + + ) +} + +// Complete agents UI with comprehensive state management +interface AgentsUIProps { + onExit: (message?: string) => void +} + +function AgentsUI({ onExit }: AgentsUIProps) { + const theme = getTheme() + + // Core state management + const [modeState, setModeState] = useState({ + mode: "list-agents", + location: "all" as AgentLocation + }) + + const [agents, setAgents] = useState([]) + const [changes, setChanges] = useState([]) + const [refreshKey, setRefreshKey] = useState(0) + const [loading, setLoading] = useState(true) + const [tools, setTools] = useState([]) + + // Creation state using reducer for complex flow management + const [createState, setCreateState] = useReducer( + (state: CreateState, action: any) => { + switch (action.type) { + case 'RESET': + return { + location: null, + agentType: '', + method: null, + generationPrompt: '', + whenToUse: '', + selectedTools: [], + selectedModel: null, + selectedColor: null, + systemPrompt: '', + isGenerating: false, + wasGenerated: false, + isAIGenerated: false, + error: null, + warnings: [], + agentTypeCursor: 0, + whenToUseCursor: 0, + promptCursor: 0, + generationPromptCursor: 0 + } + case 'SET_LOCATION': + return { ...state, location: action.value } + case 'SET_METHOD': + return { ...state, method: action.value } + case 'SET_AGENT_TYPE': + return { ...state, agentType: action.value, error: null } + case 'SET_GENERATION_PROMPT': + return { ...state, generationPrompt: action.value } + case 'SET_WHEN_TO_USE': + return { ...state, whenToUse: action.value, error: null } + case 'SET_SELECTED_TOOLS': + return { ...state, selectedTools: action.value } + case 'SET_SELECTED_MODEL': + return { ...state, selectedModel: action.value } + case 'SET_SELECTED_COLOR': + return { ...state, selectedColor: action.value } + case 'SET_SYSTEM_PROMPT': + return { ...state, systemPrompt: action.value } + case 'SET_IS_GENERATING': + return { ...state, isGenerating: action.value } + case 'SET_WAS_GENERATED': + return { ...state, wasGenerated: action.value } + case 'SET_IS_AI_GENERATED': + return { ...state, isAIGenerated: action.value } + case 'SET_ERROR': + return { ...state, error: action.value } + case 'SET_WARNINGS': + return { ...state, warnings: action.value } + case 'SET_CURSOR': + return { ...state, [action.field]: action.value } + default: + return state + } + }, + { + location: null, + agentType: '', + method: null, + generationPrompt: '', + whenToUse: '', + selectedTools: [], + selectedModel: null, + selectedColor: null, + systemPrompt: '', + isGenerating: false, + wasGenerated: false, + isAIGenerated: false, + error: null, + warnings: [], + agentTypeCursor: 0, + whenToUseCursor: 0, + promptCursor: 0, + generationPromptCursor: 0 + } + ) + + // Load agents and tools dynamically + const loadAgents = useCallback(async () => { + setLoading(true) + clearAgentCache() + + // 创建取消令牌以防止竞态条件 + const abortController = new AbortController() + const loadingId = Date.now() // 用于标识这次加载 + + try { + const result = await getActiveAgents() + + // 检查是否仍然是当前的加载请求 + if (abortController.signal.aborted) { + return // 组件已卸载或新的加载已开始 + } + + setAgents(result) + + // Update selectedAgent if there's one currently selected (for live reload) + if (modeState.selectedAgent) { + const freshSelectedAgent = result.find(a => a.agentType === modeState.selectedAgent!.agentType) + if (freshSelectedAgent) { + setModeState(prev => ({ ...prev, selectedAgent: freshSelectedAgent })) + } + } + + // Load available tools dynamically from tool registry + const availableTools: Tool[] = [] + + // Core built-in tools + let coreTools = [ + { name: 'Read', description: 'Read files from filesystem' }, + { name: 'Write', description: 'Write files to filesystem' }, + { name: 'Edit', description: 'Edit existing files' }, + { name: 'MultiEdit', description: 'Make multiple edits to files' }, + { name: 'NotebookEdit', description: 'Edit Jupyter notebooks' }, + { name: 'Bash', description: 'Execute bash commands' }, + { name: 'Glob', description: 'Find files matching patterns' }, + { name: 'Grep', description: 'Search file contents' }, + { name: 'LS', description: 'List directory contents' }, + { name: 'WebFetch', description: 'Fetch web content' }, + { name: 'WebSearch', description: 'Search the web' }, + { name: 'TodoWrite', description: 'Manage task lists' } + ] + // Hide agent orchestration/self-control tools for subagent configs + coreTools = coreTools.filter(t => t.name !== 'Task' && t.name !== 'ExitPlanMode') + + availableTools.push(...coreTools) + + // Try to load MCP tools dynamically + try { + const mcpTools = await getMCPTools() + if (Array.isArray(mcpTools) && mcpTools.length > 0) { + availableTools.push(...mcpTools) + } + } catch (error) { + console.warn('Failed to load MCP tools:', error) + } + + if (!abortController.signal.aborted) { + setTools(availableTools) + } + } catch (error) { + if (!abortController.signal.aborted) { + console.error('Failed to load agents:', error) + } + } finally { + if (!abortController.signal.aborted) { + setLoading(false) + } + } + + // 返回取消函数供useEffect使用 + return () => abortController.abort() + }, []) + + // Remove mock MCP loader; real MCP tools are loaded via getMCPTools() + + useEffect(() => { + let cleanup: (() => void) | undefined + + const load = async () => { + cleanup = await loadAgents() + } + + load() + + return () => { + if (cleanup) { + cleanup() + } + } + }, [refreshKey, loadAgents]) + + // Local file watcher removed; rely on global watcher started in CLI. + + // Global keyboard handling: ESC 逐级返回 + useInput((input, key) => { + if (!key.escape) return + + const changesSummary = changes.length > 0 ? + `Agent changes:\n${changes.join('\n')}` : undefined + + const current = modeState.mode + + if (current === 'list-agents') { + onExit(changesSummary) + return + } + + // Hierarchical back navigation + switch (current) { + case 'create-location': + setModeState({ mode: 'list-agents', location: 'all' as AgentLocation }) + break + case 'create-method': + setModeState({ mode: 'create-location', location: modeState.location }) + break + case 'create-generate': + setModeState({ mode: 'create-location', location: modeState.location }) + break + case 'create-type': + setModeState({ mode: 'create-generate', location: modeState.location }) + break + case 'create-prompt': + setModeState({ mode: 'create-type', location: modeState.location }) + break + case 'create-description': + setModeState({ mode: 'create-prompt', location: modeState.location }) + break + case 'create-tools': + setModeState({ mode: 'create-description', location: modeState.location }) + break + case 'create-model': + setModeState({ mode: 'create-tools', location: modeState.location }) + break + case 'create-color': + setModeState({ mode: 'create-model', location: modeState.location }) + break + case 'create-confirm': + setModeState({ mode: 'create-color', location: modeState.location }) + break + case 'agent-menu': + setModeState({ mode: 'list-agents', location: 'all' as AgentLocation }) + break + case 'view-agent': + setModeState({ mode: 'agent-menu', selectedAgent: modeState.selectedAgent }) + break + case 'edit-agent': + setModeState({ mode: 'agent-menu', selectedAgent: modeState.selectedAgent }) + break + case 'edit-tools': + case 'edit-model': + case 'edit-color': + setModeState({ mode: 'edit-agent', selectedAgent: modeState.selectedAgent }) + break + case 'delete-confirm': + setModeState({ mode: 'agent-menu', selectedAgent: modeState.selectedAgent }) + break + default: + setModeState({ mode: 'list-agents', location: 'all' as AgentLocation }) + break + } + }) + + // Event handlers + const handleAgentSelect = useCallback((agent: AgentConfig) => { + setModeState({ + mode: "agent-menu", + location: modeState.location, + selectedAgent: agent + }) + }, [modeState]) + + const handleCreateNew = useCallback(() => { + console.log('=== STARTING AGENT CREATION FLOW ===') + console.log('Current mode state:', modeState) + setCreateState({ type: 'RESET' }) + console.log('Reset create state') + setModeState({ mode: "create-location" }) + console.log('Set mode to create-location') + console.log('=== CREATE NEW HANDLER COMPLETED ===') + }, [modeState]) + + const handleAgentCreated = useCallback((message: string) => { + setChanges(prev => [...prev, message]) + setRefreshKey(prev => prev + 1) + setModeState({ mode: "list-agents", location: "all" as AgentLocation }) + }, []) + + const handleAgentDeleted = useCallback((message: string) => { + setChanges(prev => [...prev, message]) + setRefreshKey(prev => prev + 1) + setModeState({ mode: "list-agents", location: "all" as AgentLocation }) + }, []) + + if (loading) { + return ( + +
+ + + +
+ +
+ ) + } + + // Render based on current mode + switch (modeState.mode) { + case "list-agents": + return ( + onExit()} + onSelect={handleAgentSelect} + onCreateNew={handleCreateNew} + changes={changes} + /> + ) + + case "create-location": + return ( + + ) + + case "create-method": + return ( + + ) + + case "create-generate": + return ( + + ) + + case "create-type": + return ( + + ) + + case "create-description": + return ( + + ) + + case "create-tools": + return ( + + ) + + case "create-model": + return ( + + ) + + case "create-color": + return ( + + ) + + case "create-prompt": + return ( + + ) + + case "create-confirm": + return ( + + ) + + case "agent-menu": + return ( + + ) + + case "view-agent": + return ( + + ) + + case "edit-agent": + return ( + + ) + + case "edit-tools": + return ( + { + setChanges(prev => [...prev, message]) + setRefreshKey(prev => prev + 1) + setModeState({ mode: "agent-menu", selectedAgent: updated }) + }} + /> + ) + + case "edit-model": + return ( + { + setChanges(prev => [...prev, message]) + setRefreshKey(prev => prev + 1) + setModeState({ mode: "agent-menu", selectedAgent: updated }) + }} + /> + ) + + case "edit-color": + return ( + { + setChanges(prev => [...prev, message]) + setRefreshKey(prev => prev + 1) + setModeState({ mode: "agent-menu", selectedAgent: updated }) + }} + /> + ) + + case "delete-confirm": + return ( + + ) + + default: + return ( + +
+ Mode: {modeState.mode} (Not implemented yet) + + Press Esc to go back + +
+ +
+ ) + } +} + +interface AgentListProps { + location: AgentLocation + agents: AgentConfig[] + allAgents: AgentConfig[] + onBack: () => void + onSelect: (agent: AgentConfig) => void + onCreateNew?: () => void + changes: string[] +} + +function AgentListView({ + location, + agents, + allAgents, + onBack, + onSelect, + onCreateNew, + changes +}: AgentListProps) { + const theme = getTheme() + const allAgentsList = allAgents || agents + const customAgents = allAgentsList.filter(a => a.location !== "built-in") + const builtInAgents = allAgentsList.filter(a => a.location === "built-in") + + const [selectedAgent, setSelectedAgent] = useState(null) + const [onCreateOption, setOnCreateOption] = useState(true) + const [currentLocation, setCurrentLocation] = useState(location) + const [inLocationTabs, setInLocationTabs] = useState(false) + const [selectedLocationTab, setSelectedLocationTab] = useState(0) + + const locationTabs = [ + { label: "All", value: "all" as AgentLocation }, + { label: "Personal", value: "user" as AgentLocation }, + { label: "Project", value: "project" as AgentLocation } + ] + + const activeMap = useMemo(() => { + const map = new Map() + agents.forEach(a => map.set(a.agentType, a)) + return map + }, [agents]) + + const checkOverride = (agent: AgentConfig) => { + const active = activeMap.get(agent.agentType) + const isOverridden = !!(active && active.location !== agent.location) + return { + isOverridden, + overriddenBy: isOverridden ? active.location : null + } + } + + const renderCreateOption = () => ( + + + {onCreateOption ? `${UI_ICONS.pointer} ` : " "} + + + ✨ Create new agent + + + ) + + const renderAgent = (agent: AgentConfig, isBuiltIn = false) => { + const isSelected = !isBuiltIn && !onCreateOption && + selectedAgent?.agentType === agent.agentType && + selectedAgent?.location === agent.location + const { isOverridden, overriddenBy } = checkOverride(agent) + const dimmed = isBuiltIn || isOverridden + const color = !isBuiltIn && isSelected ? theme.primary : undefined + + // Extract model from agent metadata + const agentModel = (agent as any).model || null + const modelDisplay = getDisplayModelName(agentModel) + + return ( + + + + {isBuiltIn ? "" : isSelected ? `${UI_ICONS.pointer} ` : " "} + + + + + {agent.agentType} + + + {" · "}{modelDisplay} + + + {overriddenBy && ( + + + {UI_ICONS.warning} overridden by {overriddenBy} + + + )} + + ) + } + + const displayAgents = useMemo(() => { + if (currentLocation === "all") { + return [ + ...customAgents.filter(a => a.location === "user"), + ...customAgents.filter(a => a.location === "project") + ] + } else if (currentLocation === "user" || currentLocation === "project") { + return customAgents.filter(a => a.location === currentLocation) + } + return customAgents + }, [customAgents, currentLocation]) + + // 更新当前选中的标签索引 + useEffect(() => { + const tabIndex = locationTabs.findIndex(tab => tab.value === currentLocation) + if (tabIndex !== -1) { + setSelectedLocationTab(tabIndex) + } + }, [currentLocation, locationTabs]) + + // 确保当有agents时,初始化选择状态 + useEffect(() => { + if (displayAgents.length > 0 && !selectedAgent && !onCreateOption) { + setOnCreateOption(true) // 默认选择创建选项 + } + }, [displayAgents.length, selectedAgent, onCreateOption]) + + useInput((input, key) => { + if (key.escape) { + if (inLocationTabs) { + setInLocationTabs(false) + return + } + onBack() + return + } + + if (key.return) { + if (inLocationTabs) { + setCurrentLocation(locationTabs[selectedLocationTab].value) + setInLocationTabs(false) + return + } + if (onCreateOption && onCreateNew) { + onCreateNew() + } else if (selectedAgent) { + onSelect(selectedAgent) + } + return + } + + // Tab键进入/退出标签导航 + if (key.tab) { + setInLocationTabs(!inLocationTabs) + return + } + + // 在标签导航模式 + if (inLocationTabs) { + if (key.leftArrow) { + setSelectedLocationTab(prev => prev > 0 ? prev - 1 : locationTabs.length - 1) + } else if (key.rightArrow) { + setSelectedLocationTab(prev => prev < locationTabs.length - 1 ? prev + 1 : 0) + } + return + } + + // 键盘导航 - 这是关键缺失的功能 + if (key.upArrow || key.downArrow) { + const allNavigableItems = [] + + // 添加创建选项 + if (onCreateNew) { + allNavigableItems.push({ type: 'create', agent: null }) + } + + // 添加可导航的agents + displayAgents.forEach(agent => { + const { isOverridden } = checkOverride(agent) + if (!isOverridden) { // 只显示未被覆盖的agents + allNavigableItems.push({ type: 'agent', agent }) + } + }) + + if (allNavigableItems.length === 0) return + + if (key.upArrow) { + if (onCreateOption) { + // 从创建选项向上到最后一个agent + const lastAgent = allNavigableItems[allNavigableItems.length - 1] + if (lastAgent.type === 'agent') { + setSelectedAgent(lastAgent.agent) + setOnCreateOption(false) + } + } else if (selectedAgent) { + const currentIndex = allNavigableItems.findIndex( + item => item.type === 'agent' && + item.agent?.agentType === selectedAgent.agentType && + item.agent?.location === selectedAgent.location + ) + if (currentIndex > 0) { + const prevItem = allNavigableItems[currentIndex - 1] + if (prevItem.type === 'create') { + setOnCreateOption(true) + setSelectedAgent(null) + } else { + setSelectedAgent(prevItem.agent) + } + } else { + // 到达顶部,回到创建选项 + if (onCreateNew) { + setOnCreateOption(true) + setSelectedAgent(null) + } + } + } + } else if (key.downArrow) { + if (onCreateOption) { + // 从创建选项向下到第一个agent + const firstAgent = allNavigableItems.find(item => item.type === 'agent') + if (firstAgent) { + setSelectedAgent(firstAgent.agent) + setOnCreateOption(false) + } + } else if (selectedAgent) { + const currentIndex = allNavigableItems.findIndex( + item => item.type === 'agent' && + item.agent?.agentType === selectedAgent.agentType && + item.agent?.location === selectedAgent.location + ) + if (currentIndex < allNavigableItems.length - 1) { + const nextItem = allNavigableItems[currentIndex + 1] + if (nextItem.type === 'agent') { + setSelectedAgent(nextItem.agent) + } + } else { + // 到达底部,回到创建选项 + if (onCreateNew) { + setOnCreateOption(true) + setSelectedAgent(null) + } + } + } + } + } + }) + + // 特殊的键盘输入处理组件用于空状态 + const EmptyStateInput = () => { + useInput((input, key) => { + if (key.escape) { + onBack() + return + } + if (key.return && onCreateNew) { + onCreateNew() + return + } + }) + return null + } + + if (!agents.length || (currentLocation !== "built-in" && !customAgents.length)) { + return ( + + +
+ {onCreateNew && ( + + {renderCreateOption()} + + )} + + + 💭 What are agents? + + Specialized AI assistants that Claude can delegate to for specific tasks. + Each agent has its own context, prompt, and tools. + + + 💡 Popular agent ideas: + + + • 🔍 Code Reviewer - Reviews PRs for best practices + • 🔒 Security Auditor - Finds vulnerabilities + • ⚡ Performance Optimizer - Improves code speed + • 🧑‍💼 Tech Lead - Makes architecture decisions + • 🎨 UX Expert - Improves user experience + + + + {currentLocation !== "built-in" && builtInAgents.length > 0 && ( + <> + {UI_ICONS.separator.repeat(40)} + + Built-in (always available): + {builtInAgents.map(a => renderAgent(a, true))} + + + )} +
+ +
+ ) + } + + return ( + +
+ {changes.length > 0 && ( + + {changes[changes.length - 1]} + + )} + + {/* Fancy location tabs */} + + + {locationTabs.map((tab, idx) => { + const isActive = currentLocation === tab.value + const isSelected = inLocationTabs && idx === selectedLocationTab + return ( + + + {isSelected ? '▶ ' : isActive ? '◉ ' : '○ '} + {tab.label} + + {idx < locationTabs.length - 1 && | } + + ) + })} + + + + {currentLocation === 'all' ? 'Showing all agents' : + currentLocation === 'user' ? 'Personal agents (~/.claude/agents)' : + 'Project agents (.claude/agents)'} + + + + + + {onCreateNew && ( + + {renderCreateOption()} + + )} + + {currentLocation === "all" ? ( + <> + {customAgents.filter(a => a.location === "user").length > 0 && ( + <> + Personal: + {customAgents.filter(a => a.location === "user").map(a => renderAgent(a))} + + )} + + {customAgents.filter(a => a.location === "project").length > 0 && ( + <> + a.location === "user").length > 0 ? 1 : 0}> + Project: + + {customAgents.filter(a => a.location === "project").map(a => renderAgent(a))} + + )} + + {builtInAgents.length > 0 && ( + <> + 0 ? 1 : 0}> + {UI_ICONS.separator.repeat(40)} + + + Built-in: + {builtInAgents.map(a => renderAgent(a, true))} + + + )} + + ) : ( + <> + {displayAgents.map(a => renderAgent(a))} + {currentLocation !== "built-in" && builtInAgents.length > 0 && ( + <> + {UI_ICONS.separator.repeat(40)} + + Built-in: + {builtInAgents.map(a => renderAgent(a, true))} + + + )} + + )} + +
+ +
+ ) +} + +// Common interface for creation step props +interface StepProps { + createState: CreateState + setCreateState: React.Dispatch + setModeState: (state: ModeState) => void +} + +// Step 3: AI Generation +interface GenerateStepProps extends StepProps { + existingAgents: AgentConfig[] +} + +function GenerateStep({ createState, setCreateState, setModeState, existingAgents }: GenerateStepProps) { + const handleSubmit = async () => { + if (createState.generationPrompt.trim()) { + setCreateState({ type: 'SET_IS_GENERATING', value: true }) + setCreateState({ type: 'SET_ERROR', value: null }) + + try { + const generated = await generateAgentWithClaude(createState.generationPrompt) + + // Validate the generated identifier doesn't conflict + const validation = validateAgentType(generated.identifier, existingAgents) + let finalIdentifier = generated.identifier + + if (!validation.isValid) { + // Add a suffix to make it unique + let counter = 1 + while (true) { + const testId = `${generated.identifier}-${counter}` + const testValidation = validateAgentType(testId, existingAgents) + if (testValidation.isValid) { + finalIdentifier = testId + break + } + counter++ + if (counter > 10) { + finalIdentifier = `custom-agent-${Date.now()}` + break + } + } + } + + setCreateState({ type: 'SET_AGENT_TYPE', value: finalIdentifier }) + setCreateState({ type: 'SET_WHEN_TO_USE', value: generated.whenToUse }) + setCreateState({ type: 'SET_SYSTEM_PROMPT', value: generated.systemPrompt }) + setCreateState({ type: 'SET_WAS_GENERATED', value: true }) + setCreateState({ type: 'SET_IS_GENERATING', value: false }) + setModeState({ mode: 'create-tools', location: createState.location }) + } catch (error) { + console.error('Generation failed:', error) + setCreateState({ type: 'SET_ERROR', value: 'Failed to generate agent. Please try again or use manual configuration.' }) + setCreateState({ type: 'SET_IS_GENERATING', value: false }) + } + } + } + + return ( + +
+ + {createState.isGenerating ? ( + + {createState.generationPrompt} + + + + + ) : ( + setCreateState({ type: 'SET_GENERATION_PROMPT', value })} + placeholder="An expert that reviews pull requests for best practices, security issues, and suggests improvements..." + onSubmit={handleSubmit} + error={createState.error} + rows={3} + /> + )} + +
+ +
+ ) +} + +// Step 4: Manual type input (for manual method) +interface TypeStepProps extends StepProps { + existingAgents: AgentConfig[] +} + +function TypeStep({ createState, setCreateState, setModeState, existingAgents }: TypeStepProps) { + const handleSubmit = () => { + const validation = validateAgentType(createState.agentType, existingAgents) + if (validation.isValid) { + setModeState({ mode: 'create-prompt', location: createState.location }) + } else { + setCreateState({ type: 'SET_ERROR', value: validation.errors[0] }) + } + } + + return ( + +
+ + setCreateState({ type: 'SET_AGENT_TYPE', value })} + placeholder="e.g. code-reviewer, tech-lead" + onSubmit={handleSubmit} + /> + {createState.error && ( + + ⚠ {createState.error} + + )} + +
+ +
+ ) +} + +// Step 5: Description input +function DescriptionStep({ createState, setCreateState, setModeState }: StepProps) { + const handleSubmit = () => { + if (createState.whenToUse.trim()) { + setModeState({ mode: 'create-tools', location: createState.location }) + } + } + + return ( + +
+ + setCreateState({ type: 'SET_WHEN_TO_USE', value })} + placeholder="Use this agent when you need to review code for best practices, security issues..." + onSubmit={handleSubmit} + error={createState.error} + rows={4} + /> + +
+ +
+ ) +} + +// Step 6: Tools selection +interface ToolsStepProps extends StepProps { + tools: Tool[] +} + +function ToolsStep({ createState, setCreateState, setModeState, tools }: ToolsStepProps) { + const [selectedIndex, setSelectedIndex] = useState(0) + // Default to all tools selected initially + const initialSelection = createState.selectedTools.length > 0 ? + new Set(createState.selectedTools) : + new Set(tools.map(t => t.name)) // Select all tools by default + const [selectedTools, setSelectedTools] = useState>(initialSelection) + const [showAdvanced, setShowAdvanced] = useState(false) + const [selectedCategory, setSelectedCategory] = useState('all') + + // Categorize tools + const categorizedTools = useMemo(() => { + const categories: Record = { + read: [], + edit: [], + execution: [], + web: [], + mcp: [], + other: [] + } + + tools.forEach(tool => { + let categorized = false + + // Check MCP tools first + if (tool.name.startsWith('mcp__')) { + categories.mcp.push(tool) + categorized = true + } else { + // Check built-in categories + for (const [category, toolNames] of Object.entries(TOOL_CATEGORIES)) { + if (Array.isArray(toolNames) && toolNames.includes(tool.name)) { + categories[category as keyof typeof categories]?.push(tool) + categorized = true + break + } + } + } + + if (!categorized) { + categories.other.push(tool) + } + }) + + return categories + }, [tools]) + + const displayTools = useMemo(() => { + if (selectedCategory === 'all') { + return tools + } + return categorizedTools[selectedCategory] || [] + }, [selectedCategory, tools, categorizedTools]) + + const allSelected = selectedTools.size === tools.length && tools.length > 0 + const categoryOptions = [ + { id: 'all', label: `All (${tools.length})` }, + { id: 'read', label: `Read (${categorizedTools.read.length})` }, + { id: 'edit', label: `Edit (${categorizedTools.edit.length})` }, + { id: 'execution', label: `Execution (${categorizedTools.execution.length})` }, + { id: 'web', label: `Web (${categorizedTools.web.length})` }, + { id: 'mcp', label: `MCP (${categorizedTools.mcp.length})` }, + { id: 'other', label: `Other (${categorizedTools.other.length})` } + ].filter(cat => cat.id === 'all' || categorizedTools[cat.id]?.length > 0) + + // Calculate category selections + const readSelected = categorizedTools.read.every(tool => selectedTools.has(tool.name)) + const editSelected = categorizedTools.edit.every(tool => selectedTools.has(tool.name)) + const execSelected = categorizedTools.execution.every(tool => selectedTools.has(tool.name)) + const webSelected = categorizedTools.web.every(tool => selectedTools.has(tool.name)) + + const options: Array<{ + id: string + label: string + isContinue?: boolean + isAll?: boolean + isTool?: boolean + isCategory?: boolean + isAdvancedToggle?: boolean + isSeparator?: boolean + }> = [ + { id: 'continue', label: 'Save', isContinue: true }, + { id: 'separator1', label: '────────────────────────────────────', isSeparator: true }, + { id: 'all', label: `${allSelected ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} All tools`, isAll: true }, + { id: 'read', label: `${readSelected ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} Read-only tools`, isCategory: true }, + { id: 'edit', label: `${editSelected ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} Edit tools`, isCategory: true }, + { id: 'execution', label: `${execSelected ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} Execution tools`, isCategory: true }, + { id: 'separator2', label: '────────────────────────────────────', isSeparator: true }, + { id: 'advanced', label: `[ ${showAdvanced ? 'Hide' : 'Show'} advanced options ]`, isAdvancedToggle: true }, + ...(showAdvanced ? displayTools.map(tool => ({ + id: tool.name, + label: `${selectedTools.has(tool.name) ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} ${tool.name}`, + isTool: true + })) : []) + ] + + const handleSelect = () => { + const option = options[selectedIndex] as any // Type assertion for union type + if (!option) return + if (option.isSeparator) return + + if (option.isContinue) { + const result = allSelected ? ['*'] : Array.from(selectedTools) + setCreateState({ type: 'SET_SELECTED_TOOLS', value: result }) + setModeState({ mode: 'create-model', location: createState.location }) + } else if (option.isAdvancedToggle) { + setShowAdvanced(!showAdvanced) + } else if (option.isAll) { + if (allSelected) { + setSelectedTools(new Set()) + } else { + setSelectedTools(new Set(tools.map(t => t.name))) + } + } else if (option.isCategory) { + const categoryName = option.id as keyof typeof categorizedTools + const categoryTools = categorizedTools[categoryName] || [] + const newSelected = new Set(selectedTools) + + const categorySelected = categoryTools.every(tool => selectedTools.has(tool.name)) + if (categorySelected) { + // Unselect all tools in this category + categoryTools.forEach(tool => newSelected.delete(tool.name)) + } else { + // Select all tools in this category + categoryTools.forEach(tool => newSelected.add(tool.name)) + } + setSelectedTools(newSelected) + } else if (option.isTool) { + const newSelected = new Set(selectedTools) + if (newSelected.has(option.id)) { + newSelected.delete(option.id) + } else { + newSelected.add(option.id) + } + setSelectedTools(newSelected) + } + } + + useInput((input, key) => { + if (key.return) { + handleSelect() + } else if (key.upArrow) { + setSelectedIndex(prev => { + let newIndex = prev > 0 ? prev - 1 : options.length - 1 + // Skip separators when going up + while (options[newIndex] && (options[newIndex] as any).isSeparator) { + newIndex = newIndex > 0 ? newIndex - 1 : options.length - 1 + } + return newIndex + }) + } else if (key.downArrow) { + setSelectedIndex(prev => { + let newIndex = prev < options.length - 1 ? prev + 1 : 0 + // Skip separators when going down + while (options[newIndex] && (options[newIndex] as any).isSeparator) { + newIndex = newIndex < options.length - 1 ? newIndex + 1 : 0 + } + return newIndex + }) + } + }) + + return ( + +
+ + {options.map((option, idx) => { + const isSelected = idx === selectedIndex + const isContinue = option.isContinue + const isAdvancedToggle = option.isAdvancedToggle + const isSeparator = option.isSeparator + + return ( + + + {isSeparator ? + option.label : + `${isSelected ? `${UI_ICONS.pointer} ` : ' '}${isContinue || isAdvancedToggle ? `${option.label}` : option.label}` + } + + {option.isTool && isSelected && tools.find(t => t.name === option.id)?.description && ( + + {tools.find(t => t.name === option.id)?.description} + + )} + + ) + })} + + + + {allSelected ? + 'All tools selected' : + `${selectedTools.size} of ${tools.length} tools selected`} + + {selectedCategory !== 'all' && ( + Filtering: {selectedCategory} tools + )} + + +
+ +
+ ) +} + +// Step 6: Model selection (clean design like /models) +function ModelStep({ createState, setCreateState, setModeState }: StepProps) { + const theme = getTheme() + const manager = getModelManager() + const profiles = manager.getActiveModelProfiles() + + // Group models by provider + const groupedModels = profiles.reduce((acc: any, profile: any) => { + const provider = profile.provider || 'Default' + if (!acc[provider]) acc[provider] = [] + acc[provider].push(profile) + return acc + }, {}) + + // Flatten with inherit option + const modelOptions = [ + { id: null, name: '◈ Inherit from parent', provider: 'System', modelName: 'default' }, + ...Object.entries(groupedModels).flatMap(([provider, models]: any) => + models.map((p: any) => ({ + id: p.modelName, + name: p.name, + provider: provider, + modelName: p.modelName + })) + ) + ] + + const [selectedIndex, setSelectedIndex] = useState(() => { + const idx = modelOptions.findIndex(m => m.id === createState.selectedModel) + return idx >= 0 ? idx : 0 + }) + + const handleSelect = (modelId: string | null) => { + setCreateState({ type: 'SET_SELECTED_MODEL', value: modelId }) + setModeState({ mode: 'create-color', location: createState.location }) + } + + useInput((input, key) => { + if (key.return) { + handleSelect(modelOptions[selectedIndex].id) + } else if (key.upArrow) { + setSelectedIndex(prev => (prev > 0 ? prev - 1 : modelOptions.length - 1)) + } else if (key.downArrow) { + setSelectedIndex(prev => (prev < modelOptions.length - 1 ? prev + 1 : 0)) + } + }) + + return ( + +
+ + {modelOptions.map((model, index) => { + const isSelected = index === selectedIndex + const isInherit = model.id === null + + return ( + + + + {isSelected ? UI_ICONS.pointer : ' '} + + + + + {model.name} + + {!isInherit && ( + + {model.provider} • {model.modelName} + + )} + + + + + ) + })} + +
+ +
+ ) +} + +// Step 7: Color selection (using hex colors for display) +function ColorStep({ createState, setCreateState, setModeState }: StepProps) { + const theme = getTheme() + const [selectedIndex, setSelectedIndex] = useState(0) + + // Color options without red/green due to display issues + const colors = [ + { label: 'Default', value: null, displayColor: null }, + { label: 'Yellow', value: 'yellow', displayColor: 'yellow' }, + { label: 'Blue', value: 'blue', displayColor: 'blue' }, + { label: 'Magenta', value: 'magenta', displayColor: 'magenta' }, + { label: 'Cyan', value: 'cyan', displayColor: 'cyan' }, + { label: 'Gray', value: 'gray', displayColor: 'gray' }, + { label: 'White', value: 'white', displayColor: 'white' } + ] + + const handleSelect = (value: string | null) => { + setCreateState({ type: 'SET_SELECTED_COLOR', value: value }) + setModeState({ mode: 'create-confirm', location: createState.location }) + } + + useInput((input, key) => { + if (key.return) { + handleSelect(colors[selectedIndex].value) + } else if (key.upArrow) { + setSelectedIndex(prev => prev > 0 ? prev - 1 : colors.length - 1) + } else if (key.downArrow) { + setSelectedIndex(prev => prev < colors.length - 1 ? prev + 1 : 0) + } + }) + + return ( + +
+ + + Choose how your agent appears in the list: + + {colors.map((color, idx) => { + const isSelected = idx === selectedIndex + return ( + + + {isSelected ? '❯ ' : ' '} + + + + {color.label} + + + + ) + })} + + Preview: + + {createState.agentType || 'your-agent'} + + + +
+ +
+ ) +} + +// Step 8: System prompt +function PromptStep({ createState, setCreateState, setModeState }: StepProps) { + const handleSubmit = () => { + if (createState.systemPrompt.trim()) { + setModeState({ mode: 'create-description', location: createState.location }) + } + } + + return ( + +
+ + setCreateState({ type: 'SET_SYSTEM_PROMPT', value })} + placeholder="You are a helpful assistant that specializes in..." + onSubmit={handleSubmit} + error={createState.error} + rows={5} + /> + +
+ +
+ ) +} + +// Step 9: Confirmation +interface ConfirmStepProps extends StepProps { + tools: Tool[] + onAgentCreated: (message: string) => void +} + +function ConfirmStep({ createState, setCreateState, setModeState, tools, onAgentCreated }: ConfirmStepProps) { + const [isCreating, setIsCreating] = useState(false) + const theme = getTheme() + + const handleConfirm = async () => { + setIsCreating(true) + try { + await saveAgent( + createState.location!, + createState.agentType, + createState.whenToUse, + createState.selectedTools, + createState.systemPrompt, + createState.selectedModel, + createState.selectedColor || undefined + ) + onAgentCreated(`Created agent: ${createState.agentType}`) + } catch (error) { + setCreateState({ type: 'SET_ERROR', value: (error as Error).message }) + setIsCreating(false) + } + } + + const validation = validateAgentConfig(createState) + const toolNames = createState.selectedTools.includes('*') ? + 'All tools' : + createState.selectedTools.length > 0 ? + createState.selectedTools.join(', ') : + 'No tools' + + const handleEditInEditor = async () => { + const filePath = createState.location === 'project' + ? path.join(process.cwd(), '.claude', 'agents', `${createState.agentType}.md`) + : path.join(os.homedir(), '.claude', 'agents', `${createState.agentType}.md`) + + try { + // First, save the agent file + await saveAgent( + createState.location!, + createState.agentType, + createState.whenToUse, + createState.selectedTools, + createState.systemPrompt, + createState.selectedModel, + createState.selectedColor || undefined + ) + + // Then open it in editor + const command = process.platform === 'win32' ? 'start' : + process.platform === 'darwin' ? 'open' : 'xdg-open' + await execAsync(`${command} "${filePath}"`) + onAgentCreated(`Created agent: ${createState.agentType}`) + } catch (error) { + setCreateState({ type: 'SET_ERROR', value: (error as Error).message }) + } + } + + useInput((input, key) => { + if (isCreating) return + + if ((key.return || input === 's') && !isCreating) { + handleConfirm() + } else if (input === 'e') { + handleEditInEditor() + } else if (key.escape) { + setModeState({ mode: "create-color", location: createState.location! }) + } + }) + + return ( + +
+ + + 📋 Configuration + + + + Agent ID: {createState.agentType} + Location: {createState.location === 'project' ? 'Project' : 'Personal'} + Tools: {toolNames.length > 50 ? toolNames.slice(0, 50) + '...' : toolNames} + Model: {getDisplayModelName(createState.selectedModel)} + {createState.selectedColor && ( + Color: {createState.selectedColor} + )} + + + + 📝 Purpose + + + {createState.whenToUse} + + + {validation.warnings.length > 0 && ( + + Warnings: + {validation.warnings.map((warning, idx) => ( + • {warning} + ))} + + )} + + {createState.error && ( + + ✗ {createState.error} + + )} + + + {isCreating ? ( + + ) : null} + + +
+ +
+ ) +} + +// Step 1: Location selection +interface LocationSelectProps { + createState: CreateState + setCreateState: React.Dispatch + setModeState: (state: ModeState) => void +} + +function LocationSelect({ createState, setCreateState, setModeState }: LocationSelectProps) { + const theme = getTheme() + const [selectedIndex, setSelectedIndex] = useState(0) + + const options = [ + { label: "📁 Project", value: "project", desc: ".claude/agents/" }, + { label: "🏠 Personal", value: "user", desc: "~/.claude/agents/" } + ] + + const handleChange = (value: string) => { + setCreateState({ type: 'SET_LOCATION', value: value as AgentLocation }) + setCreateState({ type: 'SET_METHOD', value: 'generate' }) // Always use generate method + setModeState({ mode: "create-generate", location: value as AgentLocation }) + } + + const handleCancel = () => { + setModeState({ mode: "list-agents", location: "all" as AgentLocation }) + } + + useInput((input, key) => { + if (key.escape) { + handleCancel() + } else if (key.return) { + handleChange(options[selectedIndex].value) + } else if (key.upArrow) { + setSelectedIndex(prev => prev > 0 ? prev - 1 : options.length - 1) + } else if (key.downArrow) { + setSelectedIndex(prev => prev < options.length - 1 ? prev + 1 : 0) + } + }) + + return ( + +
+ + {options.map((opt, idx) => ( + + + {idx === selectedIndex ? '❯ ' : ' '}{opt.label} + + + {opt.desc} + + + ))} + +
+ +
+ ) +} + +// Step 2: Method selection +interface MethodSelectProps { + createState: CreateState + setCreateState: React.Dispatch + setModeState: (state: ModeState) => void +} + +function MethodSelect({ createState, setCreateState, setModeState }: MethodSelectProps) { + const [selectedIndex, setSelectedIndex] = useState(0) + + const options = [ + { label: "Generate with Claude (recommended)", value: "generate" }, + { label: "Manual configuration", value: "manual" } + ] + + const handleChange = (value: string) => { + setCreateState({ type: 'SET_METHOD', value: value as 'generate' | 'manual' }) + if (value === "generate") { + setCreateState({ type: 'SET_IS_AI_GENERATED', value: true }) + setModeState({ mode: "create-generate", location: createState.location }) + } else { + setCreateState({ type: 'SET_IS_AI_GENERATED', value: false }) + setModeState({ mode: "create-type", location: createState.location }) + } + } + + const handleCancel = () => { + setModeState({ mode: "create-location" }) + } + + useInput((input, key) => { + if (key.escape) { + handleCancel() + } else if (key.return) { + handleChange(options[selectedIndex].value) + } else if (key.upArrow) { + setSelectedIndex(prev => prev > 0 ? prev - 1 : options.length - 1) + } else if (key.downArrow) { + setSelectedIndex(prev => prev < options.length - 1 ? prev + 1 : 0) + } + }) + + return ( + +
+ + + +
+ +
+ ) +} + +// Agent menu for agent operations +interface AgentMenuProps { + agent: AgentConfig + setModeState: (state: ModeState) => void +} + +function AgentMenu({ agent, setModeState }: AgentMenuProps) { + const [selectedIndex, setSelectedIndex] = useState(0) + + const options = [ + { label: "View details", value: "view" }, + { label: "Edit agent", value: "edit", disabled: agent.location === 'built-in' }, + { label: "Delete agent", value: "delete", disabled: agent.location === 'built-in' } + ] + + const availableOptions = options.filter(opt => !opt.disabled) + + const handleSelect = (value: string) => { + switch (value) { + case "view": + setModeState({ mode: "view-agent", selectedAgent: agent }) + break + case "edit": + setModeState({ mode: "edit-agent", selectedAgent: agent }) + break + case "delete": + setModeState({ mode: "delete-confirm", selectedAgent: agent }) + break + } + } + + useInput((input, key) => { + if (key.return) { + handleSelect(availableOptions[selectedIndex].value) + } else if (key.upArrow) { + setSelectedIndex(prev => prev > 0 ? prev - 1 : availableOptions.length - 1) + } else if (key.downArrow) { + setSelectedIndex(prev => prev < availableOptions.length - 1 ? prev + 1 : 0) + } + }) + + return ( + +
+ + + +
+ +
+ ) +} + +// Edit menu for agent editing options +interface EditMenuProps { + agent: AgentConfig + setModeState: (state: ModeState) => void +} + +function EditMenu({ agent, setModeState }: EditMenuProps) { + const [selectedIndex, setSelectedIndex] = useState(0) + const [isOpening, setIsOpening] = useState(false) + const theme = getTheme() + + const options = [ + { label: "Open in editor", value: "open-editor" }, + { label: "Edit tools", value: "edit-tools" }, + { label: "Edit model", value: "edit-model" }, + { label: "Edit color", value: "edit-color" } + ] + + const handleSelect = async (value: string) => { + switch (value) { + case "open-editor": + setIsOpening(true) + try { + const filePath = getAgentFilePath(agent) + await openInEditor(filePath) + setModeState({ mode: "agent-menu", selectedAgent: agent }) + } catch (error) { + console.error('Failed to open editor:', error) + // TODO: Show error to user + } finally { + setIsOpening(false) + } + break + case "edit-tools": + setModeState({ mode: "edit-tools", selectedAgent: agent }) + break + case "edit-model": + setModeState({ mode: "edit-model", selectedAgent: agent }) + break + case "edit-color": + setModeState({ mode: "edit-color", selectedAgent: agent }) + break + } + } + + const handleBack = () => { + setModeState({ mode: "agent-menu", selectedAgent: agent }) + } + + useInput((input, key) => { + if (key.escape) { + handleBack() + } else if (key.return && !isOpening) { + handleSelect(options[selectedIndex].value) + } else if (key.upArrow) { + setSelectedIndex(prev => prev > 0 ? prev - 1 : options.length - 1) + } else if (key.downArrow) { + setSelectedIndex(prev => prev < options.length - 1 ? prev + 1 : 0) + } + }) + + if (isOpening) { + return ( + +
+ + + +
+ +
+ ) + } + + return ( + +
+ + + +
+ +
+ ) +} + +// Edit tools step +interface EditToolsStepProps { + agent: AgentConfig + tools: Tool[] + setModeState: (state: ModeState) => void + onAgentUpdated: (message: string, updated: AgentConfig) => void +} + +function EditToolsStep({ agent, tools, setModeState, onAgentUpdated }: EditToolsStepProps) { + const [selectedIndex, setSelectedIndex] = useState(0) + + // Initialize selected tools based on agent.tools + const initialTools = Array.isArray(agent.tools) ? agent.tools : + agent.tools === '*' ? tools.map(t => t.name) : [] + const [selectedTools, setSelectedTools] = useState>(new Set(initialTools)) + const [showAdvanced, setShowAdvanced] = useState(false) + const [isUpdating, setIsUpdating] = useState(false) + + // Categorize tools + const categorizedTools = useMemo(() => { + const categories: Record = { + read: [], + edit: [], + execution: [], + web: [], + other: [] + } + + tools.forEach(tool => { + let categorized = false + + // Check built-in categories + for (const [category, toolNames] of Object.entries(TOOL_CATEGORIES)) { + if (Array.isArray(toolNames) && toolNames.includes(tool.name)) { + categories[category as keyof typeof categories]?.push(tool) + categorized = true + break + } + } + + if (!categorized) { + categories.other.push(tool) + } + }) + + return categories + }, [tools]) + + const allSelected = selectedTools.size === tools.length && tools.length > 0 + const readSelected = categorizedTools.read.every(tool => selectedTools.has(tool.name)) && categorizedTools.read.length > 0 + const editSelected = categorizedTools.edit.every(tool => selectedTools.has(tool.name)) && categorizedTools.edit.length > 0 + const execSelected = categorizedTools.execution.every(tool => selectedTools.has(tool.name)) && categorizedTools.execution.length > 0 + + const options = [ + { id: 'continue', label: 'Save', isContinue: true }, + { id: 'separator1', label: '────────────────────────────────────', isSeparator: true }, + { id: 'all', label: `${allSelected ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} All tools`, isAll: true }, + { id: 'read', label: `${readSelected ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} Read-only tools`, isCategory: true }, + { id: 'edit', label: `${editSelected ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} Edit tools`, isCategory: true }, + { id: 'execution', label: `${execSelected ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} Execution tools`, isCategory: true }, + { id: 'separator2', label: '────────────────────────────────────', isSeparator: true }, + { id: 'advanced', label: `[ ${showAdvanced ? 'Hide' : 'Show'} advanced options ]`, isAdvancedToggle: true }, + ...(showAdvanced ? tools.map(tool => ({ + id: tool.name, + label: `${selectedTools.has(tool.name) ? UI_ICONS.checkboxOn : UI_ICONS.checkboxOff} ${tool.name}`, + isTool: true + })) : []) + ] + + const handleSave = async () => { + setIsUpdating(true) + try { + // Type-safe tools conversion for updateAgent + const toolsArray: string[] | '*' = allSelected ? '*' : Array.from(selectedTools) + await updateAgent(agent, agent.whenToUse, toolsArray, agent.systemPrompt, agent.color, (agent as any).model) + + // Clear cache and reload fresh agent data from file system + clearAgentCache() + const freshAgents = await getActiveAgents() + const updatedAgent = freshAgents.find(a => a.agentType === agent.agentType) + + if (updatedAgent) { + onAgentUpdated(`Updated tools for agent: ${agent.agentType}`, updatedAgent) + setModeState({ mode: "edit-agent", selectedAgent: updatedAgent }) + } else { + console.error('Failed to find updated agent after save') + // Fallback to manual update + const fallbackAgent: AgentConfig = { + ...agent, + tools: toolsArray.length === 1 && toolsArray[0] === '*' ? '*' : toolsArray, + } + onAgentUpdated(`Updated tools for agent: ${agent.agentType}`, fallbackAgent) + setModeState({ mode: "edit-agent", selectedAgent: fallbackAgent }) + } + } catch (error) { + console.error('Failed to update agent tools:', error) + // TODO: Show error to user + } finally { + setIsUpdating(false) + } + } + + const handleSelect = () => { + const option = options[selectedIndex] as any // Type assertion for union type + if (!option) return + if (option.isSeparator) return + + if (option.isContinue) { + handleSave() + } else if (option.isAdvancedToggle) { + setShowAdvanced(!showAdvanced) + } else if (option.isAll) { + if (allSelected) { + setSelectedTools(new Set()) + } else { + setSelectedTools(new Set(tools.map(t => t.name))) + } + } else if (option.isCategory) { + const categoryName = option.id as keyof typeof categorizedTools + const categoryTools = categorizedTools[categoryName] || [] + const newSelected = new Set(selectedTools) + + const categorySelected = categoryTools.every(tool => selectedTools.has(tool.name)) + if (categorySelected) { + categoryTools.forEach(tool => newSelected.delete(tool.name)) + } else { + categoryTools.forEach(tool => newSelected.add(tool.name)) + } + setSelectedTools(newSelected) + } else if (option.isTool) { + const newSelected = new Set(selectedTools) + if (newSelected.has(option.id)) { + newSelected.delete(option.id) + } else { + newSelected.add(option.id) + } + setSelectedTools(newSelected) + } + } + + useInput((input, key) => { + if (key.escape) { + setModeState({ mode: "edit-agent", selectedAgent: agent }) + } else if (key.return && !isUpdating) { + handleSelect() + } else if (key.upArrow) { + setSelectedIndex(prev => { + let newIndex = prev > 0 ? prev - 1 : options.length - 1 + // Skip separators when going up + while (options[newIndex] && (options[newIndex] as any).isSeparator) { + newIndex = newIndex > 0 ? newIndex - 1 : options.length - 1 + } + return newIndex + }) + } else if (key.downArrow) { + setSelectedIndex(prev => { + let newIndex = prev < options.length - 1 ? prev + 1 : 0 + // Skip separators when going down + while (options[newIndex] && (options[newIndex] as any).isSeparator) { + newIndex = newIndex < options.length - 1 ? newIndex + 1 : 0 + } + return newIndex + }) + } + }) + + if (isUpdating) { + return ( + +
+ + + +
+ +
+ ) + } + + return ( + +
+ + {options.map((option, idx) => { + const isSelected = idx === selectedIndex + const isContinue = option.isContinue + const isAdvancedToggle = (option as any).isAdvancedToggle + const isSeparator = (option as any).isSeparator + + return ( + + + {isSeparator ? + option.label : + `${isSelected ? `${UI_ICONS.pointer} ` : ' '}${isContinue || isAdvancedToggle ? option.label : option.label}` + } + + {(option as any).isTool && isSelected && tools.find(t => t.name === option.id)?.description && ( + + {tools.find(t => t.name === option.id)?.description} + + )} + + ) + })} + + + + {allSelected ? + 'All tools selected' : + `${selectedTools.size} of ${tools.length} tools selected`} + + + +
+ +
+ ) +} + +// Edit model step +interface EditModelStepProps { + agent: AgentConfig + setModeState: (state: ModeState) => void + onAgentUpdated: (message: string, updated: AgentConfig) => void +} + +function EditModelStep({ agent, setModeState, onAgentUpdated }: EditModelStepProps) { + const manager = getModelManager() + const profiles = manager.getActiveModelProfiles() + const currentModel = (agent as any).model || null + + // Build model options array + const modelOptions = [ + { id: null, name: 'Inherit from parent', description: 'Use the model from task configuration' }, + ...profiles.map((p: any) => ({ id: p.modelName, name: p.name, description: `${p.provider || 'provider'} · ${p.modelName}` })) + ] + + // Find the index of current model + const defaultIndex = modelOptions.findIndex(m => m.id === currentModel) + const [selectedIndex, setSelectedIndex] = useState(defaultIndex >= 0 ? defaultIndex : 0) + const [isUpdating, setIsUpdating] = useState(false) + + const handleSave = async (modelId: string | null) => { + setIsUpdating(true) + try { + const modelValue = modelId === null ? undefined : modelId + await updateAgent(agent, agent.whenToUse, agent.tools, agent.systemPrompt, agent.color, modelValue) + + // Clear cache and reload fresh agent data from file system + clearAgentCache() + const freshAgents = await getActiveAgents() + const updatedAgent = freshAgents.find(a => a.agentType === agent.agentType) + + if (updatedAgent) { + onAgentUpdated(`Updated model for agent: ${agent.agentType}`, updatedAgent) + setModeState({ mode: 'edit-agent', selectedAgent: updatedAgent }) + } else { + console.error('Failed to find updated agent after save') + // Fallback to manual update + const fallbackAgent: AgentConfig = { ...agent } + if (modelValue) { + (fallbackAgent as any).model = modelValue + } else { + delete (fallbackAgent as any).model + } + onAgentUpdated(`Updated model for agent: ${agent.agentType}`, fallbackAgent) + setModeState({ mode: 'edit-agent', selectedAgent: fallbackAgent }) + } + } catch (error) { + console.error('Failed to update agent model:', error) + } finally { + setIsUpdating(false) + } + } + + useInput((input, key) => { + if (key.escape) { + setModeState({ mode: 'edit-agent', selectedAgent: agent }) + } else if (key.return && !isUpdating) { + handleSave(modelOptions[selectedIndex].id) + } else if (key.upArrow) { + setSelectedIndex(prev => (prev > 0 ? prev - 1 : modelOptions.length - 1)) + } else if (key.downArrow) { + setSelectedIndex(prev => (prev < modelOptions.length - 1 ? prev + 1 : 0)) + } + }) + + if (isUpdating) { + return ( + +
+ + + +
+ +
+ ) + } + + return ( + +
+ + ({ label: `${i + 1}. ${m.name}${m.description ? `\n${m.description}` : ''}`, value: m.id }))} + selectedIndex={selectedIndex} + onChange={(val) => handleSave(val)} + numbered={false} + /> + +
+ +
+ ) +} + +// Edit color step +interface EditColorStepProps { + agent: AgentConfig + setModeState: (state: ModeState) => void + onAgentUpdated: (message: string, updated: AgentConfig) => void +} + +function EditColorStep({ agent, setModeState, onAgentUpdated }: EditColorStepProps) { + const currentColor = agent.color || null + + // Define color options (removed red/green due to display issues) + const colors = [ + { label: 'Automatic color', value: null }, + { label: 'Yellow', value: 'yellow' }, + { label: 'Blue', value: 'blue' }, + { label: 'Magenta', value: 'magenta' }, + { label: 'Cyan', value: 'cyan' }, + { label: 'Gray', value: 'gray' }, + { label: 'White', value: 'white' } + ] + + // Find current color index + const defaultIndex = colors.findIndex(color => color.value === currentColor) + const [selectedIndex, setSelectedIndex] = useState(defaultIndex >= 0 ? defaultIndex : 0) + const [isUpdating, setIsUpdating] = useState(false) + + const handleSave = async (color: string | null) => { + setIsUpdating(true) + try { + const colorValue = color === null ? undefined : color + await updateAgent(agent, agent.whenToUse, agent.tools, agent.systemPrompt, colorValue, (agent as any).model) + + // Clear cache and reload fresh agent data from file system + clearAgentCache() + const freshAgents = await getActiveAgents() + const updatedAgent = freshAgents.find(a => a.agentType === agent.agentType) + + if (updatedAgent) { + onAgentUpdated(`Updated color for agent: ${agent.agentType}`, updatedAgent) + setModeState({ mode: "edit-agent", selectedAgent: updatedAgent }) + } else { + console.error('Failed to find updated agent after save') + // Fallback to manual update + const fallbackAgent: AgentConfig = { ...agent, ...(colorValue ? { color: colorValue } : { color: undefined }) } + onAgentUpdated(`Updated color for agent: ${agent.agentType}`, fallbackAgent) + setModeState({ mode: "edit-agent", selectedAgent: fallbackAgent }) + } + } catch (error) { + console.error('Failed to update agent color:', error) + // TODO: Show error to user + } finally { + setIsUpdating(false) + } + } + + useInput((input, key) => { + if (key.escape) { + setModeState({ mode: "edit-agent", selectedAgent: agent }) + } else if (key.return && !isUpdating) { + handleSave(colors[selectedIndex].value) + } else if (key.upArrow) { + setSelectedIndex(prev => prev > 0 ? prev - 1 : colors.length - 1) + } else if (key.downArrow) { + setSelectedIndex(prev => prev < colors.length - 1 ? prev + 1 : 0) + } + }) + + if (isUpdating) { + return ( + +
+ + + +
+ +
+ ) + } + + const selectedColor = colors[selectedIndex] + const previewColor = selectedColor.value || undefined + + return ( + +
+ + {colors.map((color, index) => { + const isSelected = index === selectedIndex + const isCurrent = color.value === currentColor + + return ( + + + {isSelected ? '❯ ' : ' '} + + + + {' '}{color.label} + {isCurrent && ( + + )} + + + ) + })} + + + Preview: + {agent.agentType} + + +
+ +
+ ) +} + +// View agent details +interface ViewAgentProps { + agent: AgentConfig + tools: Tool[] + setModeState: (state: ModeState) => void +} + +function ViewAgent({ agent, tools, setModeState }: ViewAgentProps) { + const theme = getTheme() + const agentTools = Array.isArray(agent.tools) ? agent.tools : [] + const hasAllTools = agent.tools === "*" || agentTools.includes("*") + const locationPath = agent.location === 'user' + ? `~/.claude/agents/${agent.agentType}.md` + : agent.location === 'project' + ? `.claude/agents/${agent.agentType}.md` + : '(built-in)' + const displayModel = getDisplayModelName((agent as any).model || null) + + const allowedTools = useMemo(() => { + if (hasAllTools) return tools + + return tools.filter(tool => + agentTools.some(allowedTool => { + if (allowedTool.includes("*")) { + const prefix = allowedTool.replace("*", "") + return tool.name.startsWith(prefix) + } + return tool.name === allowedTool + }) + ) + }, [tools, agentTools, hasAllTools]) + + return ( + +
+ + Type: {agent.agentType} + Location: {agent.location} {locationPath !== '(built-in)' ? `· ${locationPath}` : ''} + Description: {agent.whenToUse} + Model: {displayModel} + Color: {agent.color || 'auto'} + + + Tools: + + {hasAllTools ? ( + All tools ({tools.length} available) + ) : ( + + {allowedTools.map(tool => ( + • {tool.name} + ))} + + )} + + + System Prompt: + + + {agent.systemPrompt} + + +
+ +
+ ) +} + +// Edit agent component +interface EditAgentProps { + agent: AgentConfig + tools: Tool[] + setModeState: (state: ModeState) => void + onAgentUpdated: (message: string) => void +} + +function EditAgent({ agent, tools, setModeState, onAgentUpdated }: EditAgentProps) { + const theme = getTheme() + const [currentStep, setCurrentStep] = useState<'description' | 'tools' | 'prompt' | 'confirm'>('description') + const [isUpdating, setIsUpdating] = useState(false) + + // 编辑状态 + const [editedDescription, setEditedDescription] = useState(agent.whenToUse) + const [editedTools, setEditedTools] = useState( + Array.isArray(agent.tools) ? agent.tools : agent.tools === '*' ? ['*'] : [] + ) + const [editedPrompt, setEditedPrompt] = useState(agent.systemPrompt) + const [error, setError] = useState(null) + + const handleSave = async () => { + setIsUpdating(true) + try { + await updateAgent(agent, editedDescription, editedTools, editedPrompt, agent.color) + clearAgentCache() + onAgentUpdated(`Updated agent: ${agent.agentType}`) + } catch (error) { + setError((error as Error).message) + setIsUpdating(false) + } + } + + const renderStepContent = () => { + switch (currentStep) { + case 'description': + return ( + + Edit Description: + + setCurrentStep('tools')} + error={error} + rows={4} + /> + + + ) + + case 'tools': + return ( + + Edit Tools: + + { + if (action.type === 'SET_SELECTED_TOOLS') { + setEditedTools(action.value) + setCurrentStep('prompt') + } + }} + setModeState={() => {}} + tools={tools} + /> + + + ) + + case 'prompt': + return ( + + Edit System Prompt: + + setCurrentStep('confirm')} + error={error} + rows={5} + /> + + + ) + + case 'confirm': + const validation = validateAgentConfig({ + agentType: agent.agentType, + whenToUse: editedDescription, + systemPrompt: editedPrompt, + selectedTools: editedTools + }) + + return ( + + Confirm Changes: + + Agent: {agent.agentType} + Description: {editedDescription} + Tools: {editedTools.includes('*') ? 'All tools' : editedTools.join(', ')} + System Prompt: {editedPrompt.slice(0, 100)}{editedPrompt.length > 100 ? '...' : ''} + + {validation.warnings.length > 0 && ( + + {validation.warnings.map((warning, idx) => ( + ⚠ {warning} + ))} + + )} + + {error && ( + + ✗ {error} + + )} + + + {isUpdating ? ( + + ) : ( + Press Enter to save changes + )} + + + + ) + } + } + + useInput((input, key) => { + if (key.escape) { + if (currentStep === 'description') { + setModeState({ mode: "agent-menu", selectedAgent: agent }) + } else { + // 返回上一步 + const steps: Array = ['description', 'tools', 'prompt', 'confirm'] + const currentIndex = steps.indexOf(currentStep) + if (currentIndex > 0) { + setCurrentStep(steps[currentIndex - 1]) + } + } + return + } + + if (key.return && currentStep === 'confirm' && !isUpdating) { + handleSave() + } + }) + + return ( + +
+ + {renderStepContent()} + +
+ +
+ ) +} + +// Delete confirmation +interface DeleteConfirmProps { + agent: AgentConfig + setModeState: (state: ModeState) => void + onAgentDeleted: (message: string) => void +} + +function DeleteConfirm({ agent, setModeState, onAgentDeleted }: DeleteConfirmProps) { + const [isDeleting, setIsDeleting] = useState(false) + const [selected, setSelected] = useState(false) // false = No, true = Yes + + const handleConfirm = async () => { + if (selected) { + setIsDeleting(true) + try { + await deleteAgent(agent) + clearAgentCache() + onAgentDeleted(`Deleted agent: ${agent.agentType}`) + } catch (error) { + console.error('Failed to delete agent:', error) + setIsDeleting(false) + // TODO: Show error to user + } + } else { + setModeState({ mode: "agent-menu", selectedAgent: agent }) + } + } + + useInput((input, key) => { + if (key.return) { + handleConfirm() + } else if (key.leftArrow || key.rightArrow || key.tab) { + setSelected(!selected) + } + }) + + if (isDeleting) { + return ( + +
+ + + +
+ +
+ ) + } + + return ( + +
+ + This action cannot be undone. The agent file will be permanently deleted. + + + {!selected ? `${UI_ICONS.pointer} ` : ' '}No + + + {selected ? `${UI_ICONS.pointer} ` : ' '}Yes, delete + + + +
+ +
+ ) +} + +export default { + name: 'agents', + description: 'Manage agent configurations', + type: 'local-jsx' as const, + isEnabled: true, + isHidden: false, + + async call(onExit: (message?: string) => void) { + return + }, + + userFacingName() { + return 'agents' + } +} diff --git a/src/commands/terminalSetup.ts b/src/commands/terminalSetup.ts index 9f42851..5477ac4 100644 --- a/src/commands/terminalSetup.ts +++ b/src/commands/terminalSetup.ts @@ -52,17 +52,17 @@ export function isShiftEnterKeyBindingInstalled(): boolean { } export function handleHashCommand(interpreted: string): void { - // Appends the AI-interpreted content to both KODE.md and CLAUDE.md (if exists) + // Appends the AI-interpreted content to both AGENTS.md and CLAUDE.md (if exists) try { const cwd = process.cwd() - const codeContextPath = join(cwd, 'KODE.md') + const codeContextPath = join(cwd, 'AGENTS.md') const claudePath = join(cwd, 'CLAUDE.md') // Check which files exist and update them const filesToUpdate = [] - // Always try to update KODE.md (create if not exists) - filesToUpdate.push({ path: codeContextPath, name: 'KODE.md' }) + // Always try to update AGENTS.md (create if not exists) + filesToUpdate.push({ path: codeContextPath, name: 'AGENTS.md' }) // Update CLAUDE.md only if it exists try { diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index 0529180..2a7997c 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import { type Message } from '../query' import { processUserInput } from '../utils/messages' import { useArrowKeyHistory } from '../hooks/useArrowKeyHistory' -import { useSlashCommandTypeahead } from '../hooks/useSlashCommandTypeahead' +import { useUnifiedCompletion } from '../hooks/useUnifiedCompletion' import { addToHistory } from '../history' import TextInput from './TextInput' import { memo, useCallback, useEffect, useMemo, useState } from 'react' @@ -165,19 +165,53 @@ function PromptInput({ [commands], ) + // Unified completion system - one hook to rule them all (now with terminal behavior) const { suggestions, - selectedSuggestion, - updateSuggestions, - clearSuggestions, - } = useSlashCommandTypeahead({ - commands, + selectedIndex, + isActive: completionActive, + emptyDirMessage, + } = useUnifiedCompletion({ + input, + cursorOffset, onInputChange, - onSubmit, setCursorOffset, - currentInput: input, + commands, + onSubmit, }) + // Get theme early for memoized rendering + const theme = getTheme() + + // Memoized completion suggestions rendering - after useUnifiedCompletion + const renderedSuggestions = useMemo(() => { + if (suggestions.length === 0) return null + + return suggestions.map((suggestion, index) => { + const isSelected = index === selectedIndex + const isAgent = suggestion.type === 'agent' + + // Simple color logic without complex lookups + const displayColor = isSelected + ? theme.suggestion + : (isAgent && suggestion.metadata?.color) + ? suggestion.metadata.color + : undefined + + return ( + + + {isSelected ? '◆ ' : ' '} + {suggestion.displayValue} + + + ) + }) + }, [suggestions, selectedIndex, theme.suggestion]) + const onChange = useCallback( (value: string) => { if (value.startsWith('!')) { @@ -188,10 +222,9 @@ function PromptInput({ onModeChange('koding') return } - updateSuggestions(value) onInputChange(value) }, - [onModeChange, onInputChange, updateSuggestions], + [onModeChange, onInputChange], ) // Handle Tab key model switching with simple context check @@ -237,15 +270,15 @@ function PromptInput({ input, ) - // Only use history navigation when there are 0 or 1 slash command suggestions + // Only use history navigation when there are no suggestions const handleHistoryUp = () => { - if (suggestions.length <= 1) { + if (!completionActive) { onHistoryUp() } } const handleHistoryDown = () => { - if (suggestions.length <= 1) { + if (!completionActive) { onHistoryDown() } } @@ -269,7 +302,7 @@ function PromptInput({ // Create additional context to inform Claude this is for KODING.md const kodingContext = - 'The user is using Koding mode. Format your response as a comprehensive, well-structured document suitable for adding to KODE.md. Use proper markdown formatting with headings, lists, code blocks, etc. The response should be complete and ready to add to KODE.md documentation.' + 'The user is using Koding mode. Format your response as a comprehensive, well-structured document suitable for adding to AGENTS.md. Use proper markdown formatting with headings, lists, code blocks, etc. The response should be complete and ready to add to AGENTS.md documentation.' // Switch to prompt mode but tag the submission for later capture onModeChange('prompt') @@ -325,7 +358,7 @@ function PromptInput({ } } - // If in koding mode or input starts with '#', interpret it using AI before appending to KODE.md + // If in koding mode or input starts with '#', interpret it using AI before appending to AGENTS.md else if (mode === 'koding' || input.startsWith('#')) { try { // Strip the # if we're in koding mode and the user didn't type it (since it's implied) @@ -353,7 +386,12 @@ function PromptInput({ if (isLoading) { return } - if (suggestions.length > 0 && !isSubmittingSlashCommand) { + + // Handle Enter key when completions are active + // If there are suggestions showing, Enter should complete the selection, not send the message + if (suggestions.length > 0 && completionActive) { + // The completion is handled by useUnifiedCompletion hook + // Just return to prevent message sending return } @@ -372,7 +410,7 @@ function PromptInput({ } onInputChange('') onModeChange('prompt') - clearSuggestions() + // Suggestions are now handled by unified completion setPastedImage(null) setPastedText(null) onSubmitCountChange(_ => _ + 1) @@ -463,18 +501,11 @@ function PromptInput({ return true // Explicitly handled } - // Tab key for model switching (simple and non-conflicting) - if (key.tab && !key.shift) { - handleQuickModelSwitch() - return true // Explicitly handled - } - return false // Not handled, allow other hooks }) const textInputColumns = useTerminalSize().columns - 6 const tokenUsage = useMemo(() => countTokens(messages), [messages]) - const theme = getTheme() // 🔧 Fix: Track model ID changes to detect external config updates const modelManager = getModelManager() @@ -558,14 +589,22 @@ function PromptInput({ onImagePaste={onImagePaste} columns={textInputColumns} isDimmed={isDisabled || isLoading} - disableCursorMovementForUpDownKeys={suggestions.length > 0} + disableCursorMovementForUpDownKeys={completionActive} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} onPaste={onTextPaste} + onSpecialKey={(input, key) => { + // Handle Shift+M for model switching + if (key.shift && (input === 'M' || input === 'm')) { + handleQuickModelSwitch() + return true // Prevent the 'M' from being typed + } + return false + }} /> - {suggestions.length === 0 && ( + {!completionActive && suggestions.length === 0 && ( - · # for KODE.md + · # for AGENTS.md - · / for commands · tab to switch model · esc to undo + · / for commands · shift+m to switch model · esc to undo )} @@ -624,6 +663,7 @@ function PromptInput({ } /> )} + {/* Unified completion suggestions - optimized rendering */} {suggestions.length > 0 && ( - {suggestions.map((suggestion, index) => { - const command = commands.find( - cmd => cmd.userFacingName() === suggestion.replace('/', ''), - ) - return ( - - - - /{suggestion} - {command?.aliases && command.aliases.length > 0 && ( - ({command.aliases.join(', ')}) - )} - - - {command && ( - - - - {command.description} - {command.type === 'prompt' && command.argNames?.length - ? ` (arguments: ${command.argNames.join(', ')})` - : null} - - - - )} - - ) - })} + {renderedSuggestions} + + {/* 简洁操作提示框 */} + + + {emptyDirMessage || (() => { + const selected = suggestions[selectedIndex] + if (!selected) { + return '↑↓ navigate • → accept • Tab cycle • Esc close' + } + if (selected?.value.endsWith('/')) { + return '→ enter directory • ↑↓ navigate • Tab cycle • Esc close' + } else if (selected?.type === 'agent') { + return '→ select agent • ↑↓ navigate • Tab cycle • Esc close' + } else { + return '→ insert reference • ↑↓ navigate • Tab cycle • Esc close' + } + })()} + + diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx index 47bd131..4aab367 100644 --- a/src/components/TextInput.tsx +++ b/src/components/TextInput.tsx @@ -106,6 +106,12 @@ export type Props = { * Whether to disable cursor movement for up/down arrow keys */ readonly disableCursorMovementForUpDownKeys?: boolean + + /** + * Optional callback to handle special key combinations before input processing + * Return true to prevent default handling + */ + readonly onSpecialKey?: (input: string, key: Key) => boolean readonly cursorOffset: number @@ -136,6 +142,7 @@ export default function TextInput({ onPaste, isDimmed = false, disableCursorMovementForUpDownKeys = false, + onSpecialKey, cursorOffset, onChangeCursorOffset, }: Props) { @@ -186,6 +193,12 @@ export default function TextInput({ } const wrappedOnInput = (input: string, key: Key): void => { + // Check for special key combinations first + if (onSpecialKey && onSpecialKey(input, key)) { + // Special key was handled, don't process further + return + } + // Special handling for backspace or delete if ( key.backspace || diff --git a/src/components/messages/AssistantToolUseMessage.tsx b/src/components/messages/AssistantToolUseMessage.tsx index 2de09dd..024096a 100644 --- a/src/components/messages/AssistantToolUseMessage.tsx +++ b/src/components/messages/AssistantToolUseMessage.tsx @@ -9,6 +9,7 @@ import { getTheme } from '../../utils/theme' import { BLACK_CIRCLE } from '../../constants/figures' import { ThinkTool } from '../../tools/ThinkTool/ThinkTool' import { AssistantThinkingMessage } from './AssistantThinkingMessage' +import { TaskToolMessage } from './TaskToolMessage' type Props = { param: ToolUseBlockParam @@ -61,7 +62,7 @@ export function AssistantToolUseMessage({ ) } - const userFacingToolName = tool.userFacingName() + const userFacingToolName = tool.userFacingName ? tool.userFacingName(param.input) : tool.name return ( ))} - - {userFacingToolName} - + {tool.name === 'Task' && param.input ? ( + + {userFacingToolName} + + ) : ( + + {userFacingToolName} + + )} {Object.keys(param.input as { [key: string]: unknown }).length > 0 && diff --git a/src/components/messages/TaskProgressMessage.tsx b/src/components/messages/TaskProgressMessage.tsx new file mode 100644 index 0000000..ce2de21 --- /dev/null +++ b/src/components/messages/TaskProgressMessage.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Box, Text } from 'ink' +import { getTheme } from '../../utils/theme' + +interface Props { + agentType: string + status: string + toolCount?: number +} + +export function TaskProgressMessage({ agentType, status, toolCount }: Props) { + const theme = getTheme() + + return ( + + + + + [{agentType}] + + {status} + + {toolCount && toolCount > 0 && ( + + + Tools used: {toolCount} + + + )} + + ) +} \ No newline at end of file diff --git a/src/components/messages/TaskToolMessage.tsx b/src/components/messages/TaskToolMessage.tsx new file mode 100644 index 0000000..d6b9bb5 --- /dev/null +++ b/src/components/messages/TaskToolMessage.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { Text } from 'ink' +import { getAgentByType } from '../../utils/agentLoader' +import { getTheme } from '../../utils/theme' + +interface Props { + agentType: string + children: React.ReactNode + bold?: boolean +} + +// Simple cache to prevent re-fetching agent configs +const agentConfigCache = new Map() + +export function TaskToolMessage({ agentType, children, bold = true }: Props) { + const theme = getTheme() + const [agentConfig, setAgentConfig] = useState(() => { + // Return cached config immediately if available + return agentConfigCache.get(agentType) || null + }) + + useEffect(() => { + // Skip if already cached + if (agentConfigCache.has(agentType)) { + setAgentConfig(agentConfigCache.get(agentType)) + return + } + + // Load and cache agent configuration + let mounted = true + getAgentByType(agentType).then(config => { + if (mounted) { + agentConfigCache.set(agentType, config) + setAgentConfig(config) + } + }).catch(() => { + // Silently handle errors to prevent console noise + if (mounted) { + agentConfigCache.set(agentType, null) + } + }) + + return () => { + mounted = false + } + }, [agentType]) + + // Memoize color calculation to prevent unnecessary re-renders + const color = useMemo(() => { + return agentConfig?.color || theme.text + }, [agentConfig?.color, theme.text]) + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/constants/product.ts b/src/constants/product.ts index cb8c62c..20c24c3 100644 --- a/src/constants/product.ts +++ b/src/constants/product.ts @@ -1,6 +1,6 @@ export const PRODUCT_NAME = 'Kode' export const PRODUCT_URL = 'https://github.com/shareAI-lab/Anykode' -export const PROJECT_FILE = 'KODE.md' +export const PROJECT_FILE = 'AGENTS.md' export const PRODUCT_COMMAND = 'kode' export const CONFIG_BASE_DIR = '.kode' export const CONFIG_FILE = '.kode.json' diff --git a/src/context.ts b/src/context.ts index e5fa496..006d67d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -19,13 +19,13 @@ import { lastX } from './utils/generators' import { getGitEmail } from './utils/user' import { PROJECT_FILE } from './constants/product' /** - * Find all KODE.md and CLAUDE.md files in the current working directory + * Find all AGENTS.md and CLAUDE.md files in the current working directory */ export async function getClaudeFiles(): Promise { const abortController = new AbortController() const timeout = setTimeout(() => abortController.abort(), 3000) try { - // Search for both KODE.md and CLAUDE.md files + // Search for both AGENTS.md and CLAUDE.md files const [codeContextFiles, claudeFiles] = await Promise.all([ ripGrep( ['--files', '--glob', join('**', '*', PROJECT_FILE)], @@ -46,7 +46,7 @@ export async function getClaudeFiles(): Promise { // Add instructions for additional project files const fileTypes = [] - if (codeContextFiles.length > 0) fileTypes.push('KODE.md') + if (codeContextFiles.length > 0) fileTypes.push('AGENTS.md') if (claudeFiles.length > 0) fileTypes.push('CLAUDE.md') return `NOTE: Additional project documentation files (${fileTypes.join(', ')}) were found. When working in these directories, make sure to read and follow the instructions in the corresponding files:\n${allFiles @@ -97,21 +97,21 @@ export const getReadme = memoize(async (): Promise => { }) /** - * Get project documentation content (KODE.md and CLAUDE.md) + * Get project documentation content (AGENTS.md and CLAUDE.md) */ export const getProjectDocs = memoize(async (): Promise => { try { const cwd = getCwd() - const codeContextPath = join(cwd, 'KODE.md') + const codeContextPath = join(cwd, 'AGENTS.md') const claudePath = join(cwd, 'CLAUDE.md') const docs = [] - // Try to read KODE.md + // Try to read AGENTS.md if (existsSync(codeContextPath)) { try { const content = await readFile(codeContextPath, 'utf-8') - docs.push(`# KODE.md\n\n${content}`) + docs.push(`# AGENTS.md\n\n${content}`) } catch (e) { logError(e) } diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index ea98c72..8470bac 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -185,6 +185,13 @@ async function setup(cwd: string, safeMode?: boolean): Promise { // Always grant read permissions for original working dir grantReadPermissionForOriginalDir() + + // Start watching agent configuration files for changes + const { startAgentWatcher, clearAgentCache } = await import('../utils/agentLoader') + await startAgentWatcher(() => { + // Cache is already cleared in the watcher, just log + console.log('✅ Agent configurations hot-reloaded') + }) // If --safe mode is enabled, prevent root/sudo usage for security if (safeMode) { diff --git a/src/hooks/useSlashCommandTypeahead.ts b/src/hooks/useSlashCommandTypeahead.ts deleted file mode 100644 index a92ef14..0000000 --- a/src/hooks/useSlashCommandTypeahead.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { useInput } from 'ink' -import { useState, useCallback, useEffect } from 'react' -import { Command, getCommand } from '../commands' - -type Props = { - commands: Command[] - onInputChange: (value: string) => void - onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void - setCursorOffset: (offset: number) => void - currentInput?: string // Add current input for monitoring -} - -export function useSlashCommandTypeahead({ - commands, - onInputChange, - onSubmit, - setCursorOffset, - currentInput, -}: Props): { - suggestions: string[] - selectedSuggestion: number - updateSuggestions: (value: string) => void - clearSuggestions: () => void -} { - const [suggestions, setSuggestions] = useState([]) - const [selectedSuggestion, setSelectedSuggestion] = useState(-1) - - // Force clear suggestions when input doesn't start with / - useEffect(() => { - if ( - currentInput !== undefined && - !currentInput.startsWith('/') && - suggestions.length > 0 - ) { - setSuggestions([]) - setSelectedSuggestion(-1) - } - }, [currentInput, suggestions.length]) - - function updateSuggestions(value: string) { - if (value.startsWith('/')) { - const query = value.slice(1).toLowerCase() - - // Find commands whose name or alias matches the query - const matchingCommands = commands - .filter(cmd => !cmd.isHidden) - .filter(cmd => { - const names = [cmd.userFacingName()] - if (cmd.aliases) { - names.push(...cmd.aliases) - } - return names.some(name => name.toLowerCase().startsWith(query)) - }) - - // For each matching command, include its primary name - const filtered = matchingCommands.map(cmd => cmd.userFacingName()) - setSuggestions(filtered) - - // Try to preserve the selected suggestion - const newIndex = - selectedSuggestion > -1 - ? filtered.indexOf(suggestions[selectedSuggestion]!) - : 0 - if (newIndex > -1) { - setSelectedSuggestion(newIndex) - } else { - setSelectedSuggestion(0) - } - } else { - setSuggestions([]) - setSelectedSuggestion(-1) - } - } - - useInput((_, key) => { - if (suggestions.length > 0) { - // Handle suggestion navigation (up/down arrows) - if (key.downArrow) { - setSelectedSuggestion(prev => - prev >= suggestions.length - 1 ? 0 : prev + 1, - ) - return true - } else if (key.upArrow) { - setSelectedSuggestion(prev => - prev <= 0 ? suggestions.length - 1 : prev - 1, - ) - return true - } - - // Handle selection completion via tab or return - else if (key.tab || (key.return && selectedSuggestion >= 0)) { - // Ensure a suggestion is selected - if (selectedSuggestion === -1 && key.tab) { - setSelectedSuggestion(0) - } - - const suggestionIndex = selectedSuggestion >= 0 ? selectedSuggestion : 0 - const suggestion = suggestions[suggestionIndex] - if (!suggestion) return true - - const input = '/' + suggestion + ' ' - onInputChange(input) - // Manually move cursor to end - setCursorOffset(input.length) - setSuggestions([]) - setSelectedSuggestion(-1) - - // If return was pressed and command doesn't take arguments, just run it - if (key.return) { - const command = getCommand(suggestion, commands) - if ( - command.type !== 'prompt' || - (command.argNames ?? []).length === 0 - ) { - onSubmit(input, /* isSubmittingSlashCommand */ true) - } - } - - return true - } - } - // Explicitly return false when not handling the input - return false - }) - - const clearSuggestions = useCallback(() => { - setSuggestions([]) - setSelectedSuggestion(-1) - }, []) - - return { - suggestions, - selectedSuggestion, - updateSuggestions, - clearSuggestions, - } -} diff --git a/src/hooks/useTextInput.ts b/src/hooks/useTextInput.ts index caa59bf..5b54ae7 100644 --- a/src/hooks/useTextInput.ts +++ b/src/hooks/useTextInput.ts @@ -220,6 +220,10 @@ export function useTextInput({ } function onInput(input: string, key: Key): void { + if (key.tab) { + return // Skip Tab key processing - let completion system handle it + } + // Direct handling for backspace or delete (which is being detected as delete) if ( key.backspace || @@ -277,8 +281,7 @@ export function useTextInput({ return handleMeta case key.return: return () => handleEnter(key) - case key.tab: - return () => {} + // Remove Tab handling - let completion system handle it case key.upArrow: return upOrHistoryUp case key.downArrow: diff --git a/src/hooks/useUnifiedCompletion.ts b/src/hooks/useUnifiedCompletion.ts new file mode 100644 index 0000000..6d52d0f --- /dev/null +++ b/src/hooks/useUnifiedCompletion.ts @@ -0,0 +1,1325 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { useInput } from 'ink' +import { existsSync, statSync, readdirSync } from 'fs' +import { join, dirname, basename, resolve } from 'path' +import { getCwd } from '../utils/state' +import { getCommand } from '../commands' +import { getActiveAgents } from '../utils/agentLoader' +import { getModelManager } from '../utils/model' +import { glob } from 'glob' +import { matchCommands } from '../utils/fuzzyMatcher' +import { getCommonSystemCommands, getCommandPriority } from '../utils/commonUnixCommands' +import type { Command } from '../commands' + +// Unified suggestion type for all completion types +export interface UnifiedSuggestion { + value: string + displayValue: string + type: 'command' | 'agent' | 'file' | 'ask' + icon?: string + score: number + metadata?: any + // Clean type system for smart matching + isSmartMatch?: boolean // Instead of magic string checking + originalContext?: 'mention' | 'file' | 'command' // Track source context +} + +interface CompletionContext { + type: 'command' | 'agent' | 'file' | null + prefix: string + startPos: number + endPos: number +} + +// Terminal behavior state for preview and cycling +interface TerminalState { + originalWord: string + wordContext: { start: number; end: number } | null + isPreviewMode: boolean +} + +interface Props { + input: string + cursorOffset: number + onInputChange: (value: string) => void + setCursorOffset: (offset: number) => void + commands: Command[] + onSubmit?: (value: string, isSubmittingSlashCommand?: boolean) => void +} + +/** + * Unified completion system - Linus approved + * One hook to rule them all, no bullshit, no complexity + */ +// Unified completion state - single source of truth +interface CompletionState { + suggestions: UnifiedSuggestion[] + selectedIndex: number + isActive: boolean + context: CompletionContext | null + preview: { + isActive: boolean + originalInput: string + wordRange: [number, number] + } | null + emptyDirMessage: string + suppressUntil: number // timestamp for suppression +} + +const INITIAL_STATE: CompletionState = { + suggestions: [], + selectedIndex: 0, + isActive: false, + context: null, + preview: null, + emptyDirMessage: '', + suppressUntil: 0 +} + +export function useUnifiedCompletion({ + input, + cursorOffset, + onInputChange, + setCursorOffset, + commands, + onSubmit, +}: Props) { + // Single state for entire completion system - Linus approved + const [state, setState] = useState(INITIAL_STATE) + + // State update helpers - clean and simple + const updateState = useCallback((updates: Partial) => { + setState(prev => ({ ...prev, ...updates })) + }, []) + + const resetCompletion = useCallback(() => { + setState(prev => ({ + ...prev, + suggestions: [], + selectedIndex: 0, + isActive: false, + context: null, + preview: null, + emptyDirMessage: '' + })) + }, []) + + const activateCompletion = useCallback((suggestions: UnifiedSuggestion[], context: CompletionContext) => { + setState(prev => ({ + ...prev, + suggestions: suggestions, // Keep the order from generateSuggestions (already sorted with weights) + selectedIndex: 0, + isActive: true, + context, + preview: null + })) + }, []) + + // Direct state access - no legacy wrappers needed + const { suggestions, selectedIndex, isActive, emptyDirMessage } = state + + // Find common prefix among suggestions (terminal behavior) + const findCommonPrefix = useCallback((suggestions: UnifiedSuggestion[]): string => { + if (suggestions.length === 0) return '' + if (suggestions.length === 1) return suggestions[0].value + + let prefix = suggestions[0].value + + for (let i = 1; i < suggestions.length; i++) { + const str = suggestions[i].value + let j = 0 + while (j < prefix.length && j < str.length && prefix[j] === str[j]) { + j++ + } + prefix = prefix.slice(0, j) + + if (prefix.length === 0) return '' + } + + return prefix + }, []) + + // Clean word detection - Linus approved simplicity + const getWordAtCursor = useCallback((): CompletionContext | null => { + if (!input) return null + + // IMPORTANT: Only match the word/prefix BEFORE the cursor + // Don't include text after cursor to avoid confusion + let start = cursorOffset + + // Move start backwards to find word beginning + // Stop at whitespace or special boundaries + while (start > 0) { + const char = input[start - 1] + // Stop at whitespace + if (/\s/.test(char)) break + // Keep @ and / as part of the word if they're at the beginning + if ((char === '@' || char === '/') && start < cursorOffset) { + start-- + break // Include the @ or / but stop there + } + start-- + } + + // The word is from start to cursor position (not beyond) + const word = input.slice(start, cursorOffset) + if (!word) return null + + // Priority-based type detection - no special cases needed + if (word.startsWith('/')) { + const beforeWord = input.slice(0, start).trim() + const isCommand = beforeWord === '' && !word.includes('/', 1) + return { + type: isCommand ? 'command' : 'file', + prefix: isCommand ? word.slice(1) : word, + startPos: start, + endPos: cursorOffset // Use cursor position as end + } + } + + if (word.startsWith('@')) { + const content = word.slice(1) // Remove @ + + // Check if this looks like an email (contains @ in the middle) + if (word.includes('@', 1)) { + // This looks like an email, treat as regular text + return null + } + + // Trigger completion for @mentions (agents, ask-models, files) + return { + type: 'agent', // This will trigger mixed agent+file completion + prefix: content, + startPos: start, + endPos: cursorOffset // Use cursor position as end + } + } + + // Everything else defaults to file completion + return { + type: 'file', + prefix: word, + startPos: start, + endPos: cursorOffset // Use cursor position as end + } + }, [input, cursorOffset]) + + // System commands cache - populated dynamically from $PATH + const [systemCommands, setSystemCommands] = useState([]) + const [isLoadingCommands, setIsLoadingCommands] = useState(false) + + // Dynamic command classification based on intrinsic features + const classifyCommand = useCallback((cmd: string): 'core' | 'common' | 'dev' | 'system' => { + const lowerCmd = cmd.toLowerCase() + let score = 0 + + // === FEATURE 1: Name Length & Complexity === + // Short, simple names are usually core commands + if (cmd.length <= 4) score += 40 + else if (cmd.length <= 6) score += 20 + else if (cmd.length <= 8) score += 10 + else if (cmd.length > 15) score -= 30 // Very long names are specialized + + // === FEATURE 2: Character Patterns === + // Simple alphabetic names are more likely core + if (/^[a-z]+$/.test(lowerCmd)) score += 30 + + // Mixed case, numbers, dots suggest specialized tools + if (/[A-Z]/.test(cmd)) score -= 15 + if (/\d/.test(cmd)) score -= 20 + if (cmd.includes('.')) score -= 25 + if (cmd.includes('-')) score -= 10 + if (cmd.includes('_')) score -= 15 + + // === FEATURE 3: Linguistic Patterns === + // Single, common English words + const commonWords = ['list', 'copy', 'move', 'find', 'print', 'show', 'edit', 'view'] + if (commonWords.some(word => lowerCmd.includes(word.slice(0, 3)))) score += 25 + + // Domain-specific prefixes/suffixes + const devPrefixes = ['git', 'npm', 'node', 'py', 'docker', 'kubectl'] + if (devPrefixes.some(prefix => lowerCmd.startsWith(prefix))) score += 15 + + // System/daemon indicators + const systemIndicators = ['daemon', 'helper', 'responder', 'service', 'd$', 'ctl$'] + if (systemIndicators.some(indicator => + indicator.endsWith('$') ? lowerCmd.endsWith(indicator.slice(0, -1)) : lowerCmd.includes(indicator) + )) score -= 40 + + // === FEATURE 4: File Extension Indicators === + // Commands with extensions are usually scripts/specialized tools + if (/\.(pl|py|sh|rb|js)$/.test(lowerCmd)) score -= 35 + + // === FEATURE 5: Path Location Heuristics === + // Note: We don't have path info here, but can infer from name patterns + // Commands that look like they belong in /usr/local/bin or specialized dirs + const buildToolPatterns = ['bindep', 'render', 'mako', 'webpack', 'babel', 'eslint'] + if (buildToolPatterns.some(pattern => lowerCmd.includes(pattern))) score -= 25 + + // === FEATURE 6: Vowel/Consonant Patterns === + // Unix commands often have abbreviated names with few vowels + const vowelRatio = (lowerCmd.match(/[aeiou]/g) || []).length / lowerCmd.length + if (vowelRatio < 0.2) score += 15 // Very few vowels (like 'ls', 'cp', 'mv') + if (vowelRatio > 0.5) score -= 10 // Too many vowels (usually full words) + + // === CLASSIFICATION BASED ON SCORE === + if (score >= 50) return 'core' // 50+: Core unix commands + if (score >= 20) return 'common' // 20-49: Common dev tools + if (score >= -10) return 'dev' // -10-19: Specialized dev tools + return 'system' // <-10: System/edge commands + }, []) + + // Load system commands from PATH (like real terminal) + const loadSystemCommands = useCallback(async () => { + if (systemCommands.length > 0 || isLoadingCommands) return // Already loaded or loading + + setIsLoadingCommands(true) + try { + const { readdirSync, statSync } = await import('fs') + const pathDirs = (process.env.PATH || '').split(':').filter(Boolean) + const commandSet = new Set() + + // Common fallback commands in case PATH is empty + const fallbackCommands = [ + 'ls', 'cd', 'pwd', 'cat', 'grep', 'find', 'which', 'man', 'cp', 'mv', 'rm', 'mkdir', + 'touch', 'chmod', 'ps', 'top', 'kill', 'git', 'node', 'npm', 'python', 'python3', + 'curl', 'wget', 'docker', 'vim', 'nano', 'echo', 'export', 'env', 'sudo' + ] + + // Add fallback commands first + fallbackCommands.forEach(cmd => commandSet.add(cmd)) + + // Scan PATH directories for executables + for (const dir of pathDirs) { + try { + if (readdirSync && statSync) { + const entries = readdirSync(dir) + for (const entry of entries) { + try { + const fullPath = `${dir}/${entry}` + const stats = statSync(fullPath) + // Check if it's executable (rough check) + if (stats.isFile() && (stats.mode & 0o111) !== 0) { + commandSet.add(entry) + } + } catch { + // Skip files we can't stat + } + } + } + } catch { + // Skip directories we can't read + } + } + + const commands = Array.from(commandSet).sort() + setSystemCommands(commands) + } catch (error) { + console.warn('Failed to load system commands, using fallback:', error) + // Fallback to basic commands if system scan fails + setSystemCommands([ + 'ls', 'cd', 'pwd', 'cat', 'grep', 'find', 'git', 'node', 'npm', 'python', 'vim', 'nano' + ]) + } finally { + setIsLoadingCommands(false) + } + }, [systemCommands.length, isLoadingCommands]) + + // Load commands on first use + useEffect(() => { + loadSystemCommands() + }, [loadSystemCommands]) + + // Generate command suggestions (slash commands) + const generateCommandSuggestions = useCallback((prefix: string): UnifiedSuggestion[] => { + const filteredCommands = commands.filter(cmd => !cmd.isHidden) + + if (!prefix) { + // Show all commands when prefix is empty (for single /) + return filteredCommands.map(cmd => ({ + value: cmd.userFacingName(), + displayValue: `/${cmd.userFacingName()}`, + type: 'command' as const, + score: 100, + })) + } + + return filteredCommands + .filter(cmd => { + const names = [cmd.userFacingName(), ...(cmd.aliases || [])] + return names.some(name => name.toLowerCase().startsWith(prefix.toLowerCase())) + }) + .map(cmd => ({ + value: cmd.userFacingName(), + displayValue: `/${cmd.userFacingName()}`, + type: 'command' as const, + score: 100 - prefix.length + (cmd.userFacingName().startsWith(prefix) ? 10 : 0), + })) + }, [commands]) + + // Clean Unix command scoring using fuzzy matcher + const calculateUnixCommandScore = useCallback((cmd: string, prefix: string): number => { + const result = matchCommands([cmd], prefix) + return result.length > 0 ? result[0].score : 0 + }, []) + + // Clean Unix command suggestions using fuzzy matcher with common commands boost + const generateUnixCommandSuggestions = useCallback((prefix: string): UnifiedSuggestion[] => { + if (!prefix) return [] + + // Loading state + if (isLoadingCommands) { + return [{ + value: 'loading...', + displayValue: `⏳ Loading system commands...`, + type: 'file' as const, + score: 0, + metadata: { isLoading: true } + }] + } + + // IMPORTANT: Only use commands that exist on the system (intersection) + const commonCommands = getCommonSystemCommands(systemCommands) + + // Deduplicate commands (in case of any duplicates) + const uniqueCommands = Array.from(new Set(commonCommands)) + + // Use fuzzy matcher ONLY on the unique intersection + const matches = matchCommands(uniqueCommands, prefix) + + // Boost common commands + const boostedMatches = matches.map(match => { + const priority = getCommandPriority(match.command) + return { + ...match, + score: match.score + priority * 0.5 // Add priority boost + } + }).sort((a, b) => b.score - a.score) + + // Limit results intelligently + let results = boostedMatches.slice(0, 8) + + // If we have very high scores (900+), show fewer + const perfectMatches = boostedMatches.filter(m => m.score >= 900) + if (perfectMatches.length > 0 && perfectMatches.length <= 3) { + results = perfectMatches + } + // If we have good scores (100+), prefer them + else if (boostedMatches.length > 8) { + const goodMatches = boostedMatches.filter(m => m.score >= 100) + if (goodMatches.length <= 5) { + results = goodMatches + } + } + + return results.map(item => ({ + value: item.command, + displayValue: `$ ${item.command}`, + type: 'command' as const, + score: item.score, + metadata: { isUnixCommand: true } + })) + }, [systemCommands, isLoadingCommands]) + + // Agent suggestions cache + const [agentSuggestions, setAgentSuggestions] = useState([]) + + // Model suggestions cache + const [modelSuggestions, setModelSuggestions] = useState([]) + + // Load model suggestions + useEffect(() => { + try { + const modelManager = getModelManager() + const allModels = modelManager.getAllAvailableModelNames() + + const suggestions = allModels.map(modelId => { + // Professional and clear description for expert model consultation + return { + value: `ask-${modelId}`, + displayValue: `🦜 ask-${modelId} :: Consult ${modelId} for expert opinion and specialized analysis`, + type: 'ask' as const, + score: 90, // Higher than agents - put ask-models on top + metadata: { modelId }, + } + }) + + setModelSuggestions(suggestions) + } catch (error) { + console.warn('[useUnifiedCompletion] Failed to load models:', error) + // No fallback - rely on dynamic loading only + setModelSuggestions([]) + } + }, []) + + // Load agent suggestions on mount + useEffect(() => { + getActiveAgents().then(agents => { + // agents is an array of AgentConfig, not an object + const suggestions = agents.map(config => { + // 🧠 智能描述算法 - 适应性长度控制 + let shortDesc = config.whenToUse + + // 移除常见的冗余前缀,但保留核心内容 + const prefixPatterns = [ + /^Use this agent when you need (assistance with: )?/i, + /^Use PROACTIVELY (when|to) /i, + /^Specialized in /i, + /^Implementation specialist for /i, + /^Design validation specialist\.? Use PROACTIVELY to /i, + /^Task validation specialist\.? Use PROACTIVELY to /i, + /^Requirements validation specialist\.? Use PROACTIVELY to /i + ] + + for (const pattern of prefixPatterns) { + shortDesc = shortDesc.replace(pattern, '') + } + + // 🎯 精准断句算法:中英文句号感叹号优先 → 逗号 → 省略 + const findSmartBreak = (text: string, maxLength: number) => { + if (text.length <= maxLength) return text + + // 第一优先级:中英文句号、感叹号 + const sentenceEndings = /[.!。!]/ + const firstSentenceMatch = text.search(sentenceEndings) + if (firstSentenceMatch !== -1) { + const firstSentence = text.slice(0, firstSentenceMatch).trim() + if (firstSentence.length >= 5) { + return firstSentence + } + } + + // 如果第一句过长,找逗号断句 + if (text.length > maxLength) { + const commaEndings = /[,,]/ + const commas = [] + let match + const regex = new RegExp(commaEndings, 'g') + while ((match = regex.exec(text)) !== null) { + commas.push(match.index) + } + + // 找最后一个在maxLength内的逗号 + for (let i = commas.length - 1; i >= 0; i--) { + const commaPos = commas[i] + if (commaPos < maxLength) { + const clause = text.slice(0, commaPos).trim() + if (clause.length >= 5) { + return clause + } + } + } + } + + // 最后选择:直接省略 + return text.slice(0, maxLength) + '...' + } + + shortDesc = findSmartBreak(shortDesc.trim(), 80) // 增加到80字符限制 + + // 如果处理后为空或太短,使用原始描述 + if (!shortDesc || shortDesc.length < 5) { + shortDesc = findSmartBreak(config.whenToUse, 80) + } + + return { + value: `run-agent-${config.agentType}`, + displayValue: `👤 run-agent-${config.agentType} :: ${shortDesc}`, // 人类图标 + run-agent前缀 + 简洁描述 + type: 'agent' as const, + score: 85, // Lower than ask-models + metadata: config, + } + }) + // Agents loaded successfully + setAgentSuggestions(suggestions) + }).catch((error) => { + console.warn('[useUnifiedCompletion] Failed to load agents:', error) + // No fallback - rely on dynamic loading only + setAgentSuggestions([]) + }) + }, []) + + // Generate agent and model suggestions using fuzzy matching + const generateMentionSuggestions = useCallback((prefix: string): UnifiedSuggestion[] => { + // Combine agent and model suggestions + const allSuggestions = [...agentSuggestions, ...modelSuggestions] + + if (!prefix) { + // Show all suggestions when prefix is empty (for single @) + return allSuggestions.sort((a, b) => { + // Ask models first (higher score), then agents + if (a.type === 'ask' && b.type === 'agent') return -1 + if (a.type === 'agent' && b.type === 'ask') return 1 + return b.score - a.score + }) + } + + // Use fuzzy matching for intelligent completion + const candidates = allSuggestions.map(s => s.value) + const matches = matchCommands(candidates, prefix) + + // Create result mapping with fuzzy scores + const fuzzyResults = matches + .map(match => { + const suggestion = allSuggestions.find(s => s.value === match.command)! + return { + ...suggestion, + score: match.score // Use fuzzy match score instead of simple scoring + } + }) + .sort((a, b) => { + // Ask models first (for equal scores), then agents + if (a.type === 'ask' && b.type === 'agent') return -1 + if (a.type === 'agent' && b.type === 'ask') return 1 + return b.score - a.score + }) + + return fuzzyResults + }, [agentSuggestions, modelSuggestions]) + + // Unix-style path completion - preserves user input semantics + const generateFileSuggestions = useCallback((prefix: string, isAtReference: boolean = false): UnifiedSuggestion[] => { + try { + const cwd = getCwd() + + // Parse user input preserving original format + const userPath = prefix || '.' + const isAbsolutePath = userPath.startsWith('/') + const isHomePath = userPath.startsWith('~') + + // Resolve search directory - but keep user's path format for output + let searchPath: string + if (isHomePath) { + searchPath = userPath.replace('~', process.env.HOME || '') + } else if (isAbsolutePath) { + searchPath = userPath + } else { + searchPath = resolve(cwd, userPath) + } + + // Determine search directory and filename filter + const searchStat = existsSync(searchPath) ? statSync(searchPath) : null + const searchDir = searchStat?.isDirectory() ? searchPath : dirname(searchPath) + const nameFilter = searchStat?.isDirectory() ? '' : basename(searchPath) + + if (!existsSync(searchDir)) return [] + + // Get directory entries with filter + const entries = readdirSync(searchDir) + .filter(entry => !nameFilter || entry.toLowerCase().startsWith(nameFilter.toLowerCase())) + .slice(0, 10) + + return entries.map(entry => { + const entryPath = join(searchDir, entry) + const isDir = statSync(entryPath).isDirectory() + const icon = isDir ? '📁' : '📄' + + // Unix-style path building - preserve user's original path format + let value: string + + if (userPath.includes('/')) { + // User typed path with separators - maintain structure + if (userPath.endsWith('/') || searchStat?.isDirectory()) { + // User is navigating into a directory + value = userPath.endsWith('/') + ? userPath + entry + (isDir ? '/' : '') + : userPath + '/' + entry + (isDir ? '/' : '') + } else { + // User is completing a filename - replace basename + const userDir = userPath.includes('/') ? userPath.substring(0, userPath.lastIndexOf('/')) : '' + value = userDir ? userDir + '/' + entry + (isDir ? '/' : '') : entry + (isDir ? '/' : '') + } + } else { + // User typed simple name - check if it's an existing directory + if (searchStat?.isDirectory()) { + // Existing directory - navigate into it + value = userPath + '/' + entry + (isDir ? '/' : '') + } else { + // Simple completion at current level + value = entry + (isDir ? '/' : '') + } + } + + return { + value, + displayValue: `${icon} ${entry}${isDir ? '/' : ''}`, + type: 'file' as const, + score: isDir ? 80 : 70, + } + }) + } catch { + return [] + } + }, []) + + // Unified smart matching - single algorithm with different weights + const calculateMatchScore = useCallback((suggestion: UnifiedSuggestion, prefix: string): number => { + const lowerPrefix = prefix.toLowerCase() + const value = suggestion.value.toLowerCase() + const displayValue = suggestion.displayValue.toLowerCase() + + let matchFound = false + let score = 0 + + // Check for actual matches first + if (value.startsWith(lowerPrefix)) { + matchFound = true + score = 100 // Highest priority + } else if (value.includes(lowerPrefix)) { + matchFound = true + score = 95 + } else if (displayValue.includes(lowerPrefix)) { + matchFound = true + score = 90 + } else { + // Word boundary matching for compound names like "general" -> "run-agent-general-purpose" + const words = value.split(/[-_]/) + if (words.some(word => word.startsWith(lowerPrefix))) { + matchFound = true + score = 93 + } else { + // Acronym matching (last resort) + const acronym = words.map(word => word[0]).join('') + if (acronym.startsWith(lowerPrefix)) { + matchFound = true + score = 88 + } + } + } + + // Only return score if we found a match + if (!matchFound) return 0 + + // Type preferences (small bonus) + if (suggestion.type === 'ask') score += 2 + if (suggestion.type === 'agent') score += 1 + + return score + }, []) + + // Generate smart mention suggestions without data pollution + const generateSmartMentionSuggestions = useCallback((prefix: string, sourceContext: 'file' | 'agent' = 'file'): UnifiedSuggestion[] => { + if (!prefix || prefix.length < 2) return [] + + const allSuggestions = [...agentSuggestions, ...modelSuggestions] + + return allSuggestions + .map(suggestion => { + const matchScore = calculateMatchScore(suggestion, prefix) + if (matchScore === 0) return null + + // Clean transformation without data pollution + return { + ...suggestion, + score: matchScore, + isSmartMatch: true, + originalContext: sourceContext, + // Only modify display for clarity, keep value clean + displayValue: `🎯 ${suggestion.displayValue}` + } + }) + .filter(Boolean) + .sort((a, b) => b.score - a.score) + .slice(0, 5) + }, [agentSuggestions, modelSuggestions, calculateMatchScore]) + + // Generate all suggestions based on context + const generateSuggestions = useCallback((context: CompletionContext): UnifiedSuggestion[] => { + switch (context.type) { + case 'command': + return generateCommandSuggestions(context.prefix) + case 'agent': { + // @ reference: combine mentions and files with clean priority + const mentionSuggestions = generateMentionSuggestions(context.prefix) + const fileSuggestions = generateFileSuggestions(context.prefix, true) // isAtReference=true + + // Apply weights for @ context (agents/models should be prioritized but files visible) + const weightedSuggestions = [ + ...mentionSuggestions.map(s => ({ + ...s, + // In @ context, agents/models get high priority + weightedScore: s.score + 150 + })), + ...fileSuggestions.map(s => ({ + ...s, + // Files get lower priority but still visible + weightedScore: s.score + 10 // Small boost to ensure visibility + })) + ] + + // Sort by weighted score - no artificial limits + return weightedSuggestions + .sort((a, b) => b.weightedScore - a.weightedScore) + .map(({ weightedScore, ...suggestion }) => suggestion) + // No limit or very generous limit (e.g., 30 items) + } + case 'file': { + // For normal input, try to match everything intelligently + const fileSuggestions = generateFileSuggestions(context.prefix, false) + const unixSuggestions = generateUnixCommandSuggestions(context.prefix) + + // IMPORTANT: Also try to match agents and models WITHOUT requiring @ + // This enables smart matching for inputs like "gp5", "daoqi", etc. + const mentionMatches = generateMentionSuggestions(context.prefix) + .map(s => ({ + ...s, + isSmartMatch: true, + // Show that @ will be added when selected + displayValue: `\u2192 ${s.displayValue}` // Arrow to indicate it will transform + })) + + // Apply source-based priority weights with special handling for exact matches + // Priority order: Exact Unix > Unix commands > agents/models > files + const weightedSuggestions = [ + ...unixSuggestions.map(s => ({ + ...s, + // Unix commands get boost, but exact matches get huge boost + sourceWeight: s.score >= 10000 ? 5000 : 200, // Exact match gets massive boost + weightedScore: s.score >= 10000 ? s.score + 5000 : s.score + 200 + })), + ...mentionMatches.map(s => ({ + ...s, + // Agents/models get medium priority boost (but less to avoid overriding exact Unix) + sourceWeight: 50, + weightedScore: s.score + 50 + })), + ...fileSuggestions.map(s => ({ + ...s, + // Files get no boost (baseline) + sourceWeight: 0, + weightedScore: s.score + })) + ] + + // Sort by weighted score and deduplicate + const seen = new Set() + const deduplicatedResults = weightedSuggestions + .sort((a, b) => b.weightedScore - a.weightedScore) + .filter(item => { + // Filter out duplicates based on value + if (seen.has(item.value)) return false + seen.add(item.value) + return true + }) + .map(({ weightedScore, sourceWeight, ...suggestion }) => suggestion) // Remove weight fields + // No limit - show all relevant matches + + return deduplicatedResults + } + default: + return [] + } + }, [generateCommandSuggestions, generateMentionSuggestions, generateFileSuggestions, generateUnixCommandSuggestions, generateSmartMentionSuggestions]) + + + // Complete with a suggestion - 支持万能@引用 + slash命令自动执行 + const completeWith = useCallback((suggestion: UnifiedSuggestion, context: CompletionContext) => { + let completion: string + + if (context.type === 'command') { + completion = `/${suggestion.value} ` + } else if (context.type === 'agent') { + // 🚀 万能@引用:根据建议类型决定补全格式 + if (suggestion.type === 'agent') { + completion = `@${suggestion.value} ` // 代理补全 + } else if (suggestion.type === 'ask') { + completion = `@${suggestion.value} ` // Ask模型补全 + } else { + // File reference in @mention context - no space for directories to allow expansion + const isDirectory = suggestion.value.endsWith('/') + completion = `@${suggestion.value}${isDirectory ? '' : ' '}` // 文件夹不加空格,文件加空格 + } + } else { + // Regular file completion OR smart mention matching + if (suggestion.isSmartMatch) { + // Smart mention - add @ prefix and space + completion = `@${suggestion.value} ` + } else { + // Regular file completion - no space for directories to allow expansion + const isDirectory = suggestion.value.endsWith('/') + completion = suggestion.value + (isDirectory ? '' : ' ') + } + } + + // Special handling for absolute paths in file completion + // When completing an absolute path, we should replace the entire current word/path + let actualEndPos: number + + if (context.type === 'file' && suggestion.value.startsWith('/') && !suggestion.isSmartMatch) { + // For absolute paths, find the end of the current path/word + let end = context.startPos + while (end < input.length && input[end] !== ' ' && input[end] !== '\n') { + end++ + } + actualEndPos = end + } else { + // Original logic for other cases + const currentWord = input.slice(context.startPos) + const nextSpaceIndex = currentWord.indexOf(' ') + actualEndPos = nextSpaceIndex === -1 ? input.length : context.startPos + nextSpaceIndex + } + + const newInput = input.slice(0, context.startPos) + completion + input.slice(actualEndPos) + onInputChange(newInput) + setCursorOffset(context.startPos + completion.length) + + // Don't auto-execute slash commands - let user press Enter to submit + // This gives users a chance to add arguments or modify the command + + // Completion applied + }, [input, onInputChange, setCursorOffset, onSubmit, commands]) + + // Partial complete to common prefix + const partialComplete = useCallback((prefix: string, context: CompletionContext) => { + const completion = context.type === 'command' ? `/${prefix}` : + context.type === 'agent' ? `@${prefix}` : + prefix + + const newInput = input.slice(0, context.startPos) + completion + input.slice(context.endPos) + onInputChange(newInput) + setCursorOffset(context.startPos + completion.length) + }, [input, onInputChange, setCursorOffset]) + + + // Handle Tab key - simplified and unified + useInput((input_str, key) => { + if (!key.tab || key.shift) return false + + const context = getWordAtCursor() + if (!context) return false + + // If menu is already showing, cycle through suggestions + if (state.isActive && state.suggestions.length > 0) { + const nextIndex = (state.selectedIndex + 1) % state.suggestions.length + const nextSuggestion = state.suggestions[nextIndex] + + if (state.context) { + // Calculate proper word boundaries + const currentWord = input.slice(state.context.startPos) + const wordEnd = currentWord.search(/\s/) + const actualEndPos = wordEnd === -1 + ? input.length + : state.context.startPos + wordEnd + + // Apply appropriate prefix based on context type and suggestion type + let preview: string + if (state.context.type === 'command') { + preview = `/${nextSuggestion.value}` + } else if (state.context.type === 'agent') { + // For @mentions, always add @ prefix + preview = `@${nextSuggestion.value}` + } else if (nextSuggestion.isSmartMatch) { + // Smart match from normal input - add @ prefix + preview = `@${nextSuggestion.value}` + } else { + preview = nextSuggestion.value + } + + // Apply preview + const newInput = input.slice(0, state.context.startPos) + + preview + + input.slice(actualEndPos) + + onInputChange(newInput) + setCursorOffset(state.context.startPos + preview.length) + + // Update state + updateState({ + selectedIndex: nextIndex, + preview: { + isActive: true, + originalInput: input, + wordRange: [state.context.startPos, state.context.startPos + preview.length] + } + }) + } + return true + } + + // Generate new suggestions + const currentSuggestions = generateSuggestions(context) + + if (currentSuggestions.length === 0) { + return false // Let Tab pass through + } else if (currentSuggestions.length === 1) { + // Single match: complete immediately + completeWith(currentSuggestions[0], context) + return true + } else { + // Show menu and apply first suggestion + activateCompletion(currentSuggestions, context) + + // Immediately apply first suggestion as preview + const firstSuggestion = currentSuggestions[0] + const currentWord = input.slice(context.startPos) + const wordEnd = currentWord.search(/\s/) + const actualEndPos = wordEnd === -1 + ? input.length + : context.startPos + wordEnd + + let preview: string + if (context.type === 'command') { + preview = `/${firstSuggestion.value}` + } else if (context.type === 'agent') { + preview = `@${firstSuggestion.value}` + } else if (firstSuggestion.isSmartMatch) { + // Smart match from normal input - add @ prefix + preview = `@${firstSuggestion.value}` + } else { + preview = firstSuggestion.value + } + + const newInput = input.slice(0, context.startPos) + + preview + + input.slice(actualEndPos) + + onInputChange(newInput) + setCursorOffset(context.startPos + preview.length) + + updateState({ + preview: { + isActive: true, + originalInput: input, + wordRange: [context.startPos, context.startPos + preview.length] + } + }) + + return true + } + }) + + // Handle navigation keys - simplified and unified + useInput((_, key) => { + // Enter key - confirm selection and end completion (always add space) + if (key.return && state.isActive && state.suggestions.length > 0) { + const selectedSuggestion = state.suggestions[state.selectedIndex] + if (selectedSuggestion && state.context) { + // For Enter key, always add space even for directories to indicate completion end + let completion: string + + if (state.context.type === 'command') { + completion = `/${selectedSuggestion.value} ` + } else if (state.context.type === 'agent') { + if (selectedSuggestion.type === 'agent') { + completion = `@${selectedSuggestion.value} ` + } else if (selectedSuggestion.type === 'ask') { + completion = `@${selectedSuggestion.value} ` + } else { + // File reference in @mention context - always add space on Enter + completion = `@${selectedSuggestion.value} ` + } + } else if (selectedSuggestion.isSmartMatch) { + // Smart match from normal input - add @ prefix + completion = `@${selectedSuggestion.value} ` + } else { + // Regular file completion - always add space on Enter + completion = selectedSuggestion.value + ' ' + } + + // Apply completion with forced space + const currentWord = input.slice(state.context.startPos) + const nextSpaceIndex = currentWord.indexOf(' ') + const actualEndPos = nextSpaceIndex === -1 ? input.length : state.context.startPos + nextSpaceIndex + + const newInput = input.slice(0, state.context.startPos) + completion + input.slice(actualEndPos) + onInputChange(newInput) + setCursorOffset(state.context.startPos + completion.length) + } + resetCompletion() + return true + } + + if (!state.isActive || state.suggestions.length === 0) return false + + // Arrow key navigation with preview + const handleNavigation = (newIndex: number) => { + const preview = state.suggestions[newIndex].value + + if (state.preview?.isActive && state.context) { + const newInput = input.slice(0, state.context.startPos) + + preview + + input.slice(state.preview.wordRange[1]) + + onInputChange(newInput) + setCursorOffset(state.context.startPos + preview.length) + + updateState({ + selectedIndex: newIndex, + preview: { + ...state.preview, + wordRange: [state.context.startPos, state.context.startPos + preview.length] + } + }) + } else { + updateState({ selectedIndex: newIndex }) + } + } + + if (key.downArrow) { + const nextIndex = (state.selectedIndex + 1) % state.suggestions.length + handleNavigation(nextIndex) + return true + } + + if (key.upArrow) { + const nextIndex = state.selectedIndex === 0 + ? state.suggestions.length - 1 + : state.selectedIndex - 1 + handleNavigation(nextIndex) + return true + } + + // Space key - complete and potentially continue for directories + if (key.space && state.isActive && state.suggestions.length > 0) { + const selectedSuggestion = state.suggestions[state.selectedIndex] + const isDirectory = selectedSuggestion.value.endsWith('/') + + if (!state.context) return false + + // Apply completion if needed + const currentWordAtContext = input.slice(state.context.startPos, + state.context.startPos + selectedSuggestion.value.length) + + if (currentWordAtContext !== selectedSuggestion.value) { + completeWith(selectedSuggestion, state.context) + } + + resetCompletion() + + if (isDirectory) { + // Continue completion for directories + setTimeout(() => { + const newContext = { + ...state.context, + prefix: selectedSuggestion.value, + endPos: state.context.startPos + selectedSuggestion.value.length + } + + const newSuggestions = generateSuggestions(newContext) + + if (newSuggestions.length > 0) { + activateCompletion(newSuggestions, newContext) + } else { + updateState({ + emptyDirMessage: `Directory is empty: ${selectedSuggestion.value}` + }) + setTimeout(() => updateState({ emptyDirMessage: '' }), 3000) + } + }, 50) + } + + return true + } + + // Right arrow key - same as space but different semantics + if (key.rightArrow) { + const selectedSuggestion = state.suggestions[state.selectedIndex] + const isDirectory = selectedSuggestion.value.endsWith('/') + + if (!state.context) return false + + // Apply completion + const currentWordAtContext = input.slice(state.context.startPos, + state.context.startPos + selectedSuggestion.value.length) + + if (currentWordAtContext !== selectedSuggestion.value) { + completeWith(selectedSuggestion, state.context) + } + + resetCompletion() + + if (isDirectory) { + // Continue for directories + setTimeout(() => { + const newContext = { + ...state.context, + prefix: selectedSuggestion.value, + endPos: state.context.startPos + selectedSuggestion.value.length + } + + const newSuggestions = generateSuggestions(newContext) + + if (newSuggestions.length > 0) { + activateCompletion(newSuggestions, newContext) + } else { + updateState({ + emptyDirMessage: `Directory is empty: ${selectedSuggestion.value}` + }) + setTimeout(() => updateState({ emptyDirMessage: '' }), 3000) + } + }, 50) + } + + return true + } + + if (key.escape) { + // Restore original text if in preview mode + if (state.preview?.isActive && state.context) { + onInputChange(state.preview.originalInput) + setCursorOffset(state.context.startPos + state.context.prefix.length) + } + + resetCompletion() + return true + } + + return false + }) + + // Handle delete/backspace keys - unified state management + useInput((input_str, key) => { + if (key.backspace || key.delete) { + if (state.isActive) { + resetCompletion() + // Smart suppression based on input complexity + const suppressionTime = input.length > 10 ? 200 : 100 + updateState({ + suppressUntil: Date.now() + suppressionTime + }) + return true + } + } + return false + }) + + // Input tracking with ref to avoid infinite loops + const lastInputRef = useRef('') + + // Smart auto-triggering with cycle prevention + useEffect(() => { + // Prevent infinite loops by using ref + if (lastInputRef.current === input) return + + const inputLengthChange = Math.abs(input.length - lastInputRef.current.length) + const isHistoryNavigation = ( + inputLengthChange > 10 || // Large content change + (inputLengthChange > 5 && !input.includes(lastInputRef.current.slice(-5))) // Different content + ) && input !== lastInputRef.current + + // Update ref (no state update) + lastInputRef.current = input + + // Skip if in preview mode or suppressed + if (state.preview?.isActive || Date.now() < state.suppressUntil) { + return + } + + // Clear suggestions on history navigation + if (isHistoryNavigation && state.isActive) { + resetCompletion() + return + } + + const context = getWordAtCursor() + + if (context && shouldAutoTrigger(context)) { + const newSuggestions = generateSuggestions(context) + + if (newSuggestions.length === 0) { + resetCompletion() + } else if (newSuggestions.length === 1 && shouldAutoHideSingleMatch(newSuggestions[0], context)) { + resetCompletion() // Perfect match - hide + } else { + activateCompletion(newSuggestions, context) + } + } else if (state.context) { + // Check if context changed significantly + const contextChanged = !context || + state.context.type !== context.type || + state.context.startPos !== context.startPos || + !context.prefix.startsWith(state.context.prefix) + + if (contextChanged) { + resetCompletion() + } + } + }, [input, cursorOffset]) + + // Smart triggering - only when it makes sense + const shouldAutoTrigger = useCallback((context: CompletionContext): boolean => { + switch (context.type) { + case 'command': + // Trigger immediately for slash commands + return true + case 'agent': + // Trigger immediately for agent references + return true + case 'file': + // Be selective about file completion - avoid noise + const prefix = context.prefix + + // Always trigger for clear path patterns + if (prefix.startsWith('/') || prefix.startsWith('~') || prefix.includes('/')) { + return true + } + + // Only trigger for extensions with reasonable filename length + if (prefix.includes('.') && prefix.length >= 3) { + return true + } + + // Skip very short prefixes that are likely code (a.b, x.y) + return false + default: + return false + } + }, []) + + // Helper function to determine if single suggestion should be auto-hidden + const shouldAutoHideSingleMatch = useCallback((suggestion: UnifiedSuggestion, context: CompletionContext): boolean => { + // Extract the actual typed input from context + const currentInput = input.slice(context.startPos, context.endPos) + // Check if should auto-hide single match + + // For files: more intelligent matching + if (context.type === 'file') { + // Special case: if suggestion is a directory (ends with /), don't auto-hide + // because user might want to continue navigating into it + if (suggestion.value.endsWith('/')) { + // Directory suggestion, keeping visible + return false + } + + // Check exact match + if (currentInput === suggestion.value) { + // Exact match, hiding + return true + } + + // Check if current input is a complete file path and suggestion is just the filename + // e.g., currentInput: "src/tools/ThinkTool/ThinkTool.tsx", suggestion: "ThinkTool.tsx" + if (currentInput.endsWith('/' + suggestion.value) || currentInput.endsWith(suggestion.value)) { + // Path ends with suggestion, hiding + return true + } + + return false + } + + // For commands: check if /prefix exactly matches /command + if (context.type === 'command') { + const fullCommand = `/${suggestion.value}` + const matches = currentInput === fullCommand + // Check command match + return matches + } + + // For agents: check if @prefix exactly matches @agent-name + if (context.type === 'agent') { + const fullAgent = `@${suggestion.value}` + const matches = currentInput === fullAgent + // Check agent match + return matches + } + + return false + }, [input]) + + return { + suggestions, + selectedIndex, + isActive, + emptyDirMessage, + } +} \ No newline at end of file diff --git a/src/query.ts b/src/query.ts index 7875606..9ef3413 100644 --- a/src/query.ts +++ b/src/query.ts @@ -410,6 +410,7 @@ export async function* runToolUse( currentRequest?.id, ) + logEvent('tengu_tool_use_start', { toolName: toolUse.name, toolUseID: toolUse.id, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 4c72117..d6b5f62 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -181,7 +181,12 @@ export function REPL({ // Tool use confirm handles the abort signal itself toolUseConfirm.onAbort() } else { - abortController?.abort() + // Wrap abort in try-catch to prevent error display on user interrupt + try { + abortController?.abort() + } catch (e) { + // Silently handle abort errors - this is expected behavior + } } } @@ -392,7 +397,7 @@ export function REPL({ } // If this was a Koding request and we got an assistant message back, - // save it to KODE.md (and CLAUDE.md if exists) + // save it to AGENTS.md (and CLAUDE.md if exists) if ( isKodingRequest && lastAssistantMessage && @@ -407,7 +412,7 @@ export function REPL({ .map(block => (block.type === 'text' ? block.text : '')) .join('\n') - // Add the content to KODE.md (and CLAUDE.md if exists) + // Add the content to AGENTS.md (and CLAUDE.md if exists) if (content && content.trim().length > 0) { handleHashCommand(content) } diff --git a/src/services/claude.ts b/src/services/claude.ts index 7e61633..9d0237e 100644 --- a/src/services/claude.ts +++ b/src/services/claude.ts @@ -81,7 +81,7 @@ function getModelConfigForDebug(model: string): { const config = getGlobalConfig() const modelManager = getModelManager() - // 🔧 Fix: Use ModelManager to get the actual current model profile + const modelProfile = modelManager.getModel('main') let apiKeyStatus: 'configured' | 'missing' | 'invalid' = 'missing' @@ -89,7 +89,7 @@ function getModelConfigForDebug(model: string): { let maxTokens: number | undefined let reasoningEffort: string | undefined - // 🔧 Fix: Use ModelProfile configuration exclusively + if (modelProfile) { apiKeyStatus = modelProfile.apiKey ? 'configured' : 'missing' baseURL = modelProfile.baseURL @@ -320,7 +320,7 @@ async function withRetry( ) { throw error } - // 🔧 CRITICAL FIX: Check abort signal BEFORE showing retry message + if (options.signal?.aborted) { throw new Error('Request cancelled by user') } @@ -439,7 +439,7 @@ export async function verifyApiKey( 'Content-Type': 'application/json', } - // 🔧 Fix: Proper URL construction for verification + if (!baseURL) { console.warn( 'No baseURL provided for non-Anthropic provider verification', @@ -647,7 +647,7 @@ function messageReducer( } async function handleMessageStream( stream: ChatCompletionStream, - signal?: AbortSignal, // 🔧 Add AbortSignal support to stream handler + signal?: AbortSignal, ): Promise { const streamStartTime = Date.now() let ttftMs: number | undefined @@ -663,7 +663,7 @@ async function handleMessageStream( let id, model, created, object, usage try { for await (const chunk of stream) { - // 🔧 CRITICAL FIX: Check abort signal in OpenAI streaming loop + if (signal?.aborted) { debugLogger.flow('OPENAI_STREAM_ABORTED', { chunkCount, @@ -766,7 +766,7 @@ async function handleMessageStream( } } -function convertOpenAIResponseToAnthropic(response: OpenAI.ChatCompletion) { +function convertOpenAIResponseToAnthropic(response: OpenAI.ChatCompletion, tools?: Tool[]) { let contentBlocks: ContentBlock[] = [] const message = response.choices?.[0]?.message if (!message) { @@ -785,12 +785,12 @@ function convertOpenAIResponseToAnthropic(response: OpenAI.ChatCompletion) { if (message?.tool_calls) { for (const toolCall of message.tool_calls) { const tool = toolCall.function - const toolName = tool.name + const toolName = tool?.name let toolArgs = {} try { - toolArgs = JSON.parse(tool.arguments) + toolArgs = tool?.arguments ? JSON.parse(tool.arguments) : {} } catch (e) { - // console.log(e) + // Invalid JSON in tool arguments } contentBlocks.push({ @@ -835,6 +835,7 @@ function convertOpenAIResponseToAnthropic(response: OpenAI.ChatCompletion) { usage: response.usage, } + return finalMessage } @@ -1060,7 +1061,7 @@ export async function queryLLM( toolUseContext?: ToolUseContext }, ): Promise { - // 🔧 统一的模型解析:支持指针、model ID 和真实模型名称 + const modelManager = getModelManager() const modelResolution = modelManager.resolveModelWithInfo(options.model) @@ -1248,7 +1249,7 @@ async function queryLLMWithPromptCaching( const modelManager = getModelManager() const toolUseContext = options.toolUseContext - // 🔧 Fix: 使用传入的ModelProfile,而不是硬编码的'main'指针 + const modelProfile = options.modelProfile || modelManager.getModel('main') let provider: string @@ -1300,7 +1301,7 @@ async function queryAnthropicNative( const modelManager = getModelManager() const toolUseContext = options?.toolUseContext - // 🔧 Fix: 使用传入的ModelProfile,而不是硬编码的'main'指针 + const modelProfile = options?.modelProfile || modelManager.getModel('main') let anthropic: Anthropic | AnthropicBedrock | AnthropicVertex let model: string @@ -1390,13 +1391,16 @@ async function queryAnthropicNative( }), ) - const toolSchemas = tools.map( - tool => + const toolSchemas = await Promise.all( + tools.map(async tool => ({ name: tool.name, - description: tool.description, + description: typeof tool.description === 'function' + ? await tool.description() + : tool.description, input_schema: zodToJsonSchema(tool.inputSchema), }) as unknown as Anthropic.Beta.Messages.BetaTool, + ) ) const anthropicMessages = addCacheBreakpoints(messages) @@ -1456,7 +1460,7 @@ async function queryAnthropicNative( }) if (config.stream) { - // 🔧 CRITICAL FIX: Connect AbortSignal to Anthropic API call + const stream = await anthropic.beta.messages.create({ ...params, stream: true, @@ -1472,7 +1476,7 @@ async function queryAnthropicNative( let stopSequence: string | null = null for await (const event of stream) { - // 🔧 CRITICAL FIX: Check abort signal in streaming loop + if (signal.aborted) { debugLogger.flow('STREAM_ABORTED', { eventType: event.type, @@ -1546,12 +1550,12 @@ async function queryAnthropicNative( modelProfileName: modelProfile?.name, }) - // 🔧 CRITICAL FIX: Connect AbortSignal to non-streaming API call + return await anthropic.beta.messages.create(params, { signal: signal // ← CRITICAL: Connect the AbortSignal to API call }) } - }, { signal }) // 🔧 CRITICAL FIX: Pass AbortSignal to withRetry + }, { signal }) const ttftMs = start - Date.now() const durationMs = Date.now() - startIncludingRetries @@ -1705,7 +1709,7 @@ async function queryOpenAI( const modelManager = getModelManager() const toolUseContext = options?.toolUseContext - // 🔧 Fix: 使用传入的ModelProfile,而不是硬编码的'main'指针 + const modelProfile = options?.modelProfile || modelManager.getModel('main') let model: string @@ -1806,10 +1810,10 @@ async function queryOpenAI( const opts: OpenAI.ChatCompletionCreateParams = { model, - // 🔧 Use correct parameter name based on model type + ...(isGPT5 ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens }), messages: [...openaiSystem, ...openaiMessages], - // 🔧 GPT-5 temperature constraint: only 1 or undefined + temperature: isGPT5 ? 1 : MAIN_QUERY_TEMPERATURE, } if (config.stream) { @@ -1831,7 +1835,7 @@ async function queryOpenAI( opts.reasoning_effort = reasoningEffort } - // 🔧 Fix: 如果有ModelProfile配置,直接使用它 (更宽松的条件) + if (modelProfile && modelProfile.modelName) { debugLogger.api('USING_MODEL_PROFILE_PATH', { modelProfileName: modelProfile.modelName, @@ -1900,7 +1904,7 @@ async function queryOpenAI( } else { finalResponse = s } - const r = convertOpenAIResponseToAnthropic(finalResponse) + const r = convertOpenAIResponseToAnthropic(finalResponse, tools) return r } } else { @@ -1915,7 +1919,7 @@ async function queryOpenAI( } else { finalResponse = s } - const r = convertOpenAIResponseToAnthropic(finalResponse) + const r = convertOpenAIResponseToAnthropic(finalResponse, tools) return r } } else { @@ -1942,7 +1946,7 @@ async function queryOpenAI( `No valid ModelProfile available for model: ${model}. Please configure model through /model command. Debug: ${JSON.stringify(errorDetails)}`, ) } - }, { signal }) // 🔧 CRITICAL FIX: Pass AbortSignal to withRetry + }, { signal }) } catch (error) { logError(error) return getAssistantMessageFromError(error) diff --git a/src/services/customCommands.ts b/src/services/customCommands.ts index a451ee2..46e09ab 100644 --- a/src/services/customCommands.ts +++ b/src/services/customCommands.ts @@ -77,6 +77,8 @@ export async function executeBashCommands(content: string): Promise { */ export async function resolveFileReferences(content: string): Promise { // Match patterns like @src/file.js or @path/to/file.txt + // Use consistent file mention pattern from mentionProcessor + // Exclude agent and ask-model patterns to avoid conflicts const fileRefRegex = /@([a-zA-Z0-9/._-]+(?:\.[a-zA-Z0-9]+)?)/g const matches = [...content.matchAll(fileRefRegex)] @@ -90,6 +92,11 @@ export async function resolveFileReferences(content: string): Promise { const fullMatch = match[0] const filePath = match[1] + // Skip agent mentions - these are handled by the mention processor + if (filePath.startsWith('agent-')) { + continue + } + try { // Resolve relative to current working directory // This maintains consistency with how other file operations work diff --git a/src/services/mentionProcessor.ts b/src/services/mentionProcessor.ts new file mode 100644 index 0000000..aac486f --- /dev/null +++ b/src/services/mentionProcessor.ts @@ -0,0 +1,273 @@ +/** + * Mention Processor Service + * Handles @agent and @file mentions through the system reminder infrastructure + * Designed to integrate naturally with the existing event-driven architecture + */ + +import { emitReminderEvent } from './systemReminder' +import { getAvailableAgentTypes } from '../utils/agentLoader' +import { existsSync } from 'fs' +import { resolve } from 'path' +import { getCwd } from '../utils/state' + +export interface MentionContext { + type: 'agent' | 'file' + mention: string + resolved: string + exists: boolean + metadata?: any +} + +export interface ProcessedMentions { + agents: MentionContext[] + files: MentionContext[] + hasAgentMentions: boolean + hasFileMentions: boolean +} + +class MentionProcessorService { + // Centralized mention patterns - single source of truth + private static readonly MENTION_PATTERNS = { + runAgent: /@(run-agent-[\w\-]+)/g, + agent: /@(agent-[\w\-]+)/g, // Legacy support + askModel: /@(ask-[\w\-]+)/g, + file: /@([a-zA-Z0-9/._-]+(?:\.[a-zA-Z0-9]+)?)/g + } as const + + private agentCache: Map = new Map() + private lastAgentCheck: number = 0 + private CACHE_TTL = 60000 // 1 minute cache + + /** + * Process mentions in user input and emit appropriate events + * This follows the event-driven philosophy of system reminders + */ + public async processMentions(input: string): Promise { + const result: ProcessedMentions = { + agents: [], + files: [], + hasAgentMentions: false, + hasFileMentions: false, + } + + try { + + // Process agent mentions with unified logic to eliminate code duplication + const agentMentions = this.extractAgentMentions(input) + if (agentMentions.length > 0) { + await this.refreshAgentCache() + + for (const { mention, agentType, isAskModel } of agentMentions) { + if (isAskModel || this.agentCache.has(agentType)) { + result.agents.push({ + type: 'agent', + mention, + resolved: agentType, + exists: true, + metadata: isAskModel ? { type: 'ask-model' } : undefined + }) + result.hasAgentMentions = true + + // Emit appropriate event based on mention type + this.emitAgentMentionEvent(mention, agentType, isAskModel) + } + } + } + + // No longer process @xxx format - treat as regular text (emails, etc.) + + // Process file mentions (exclude agent and ask-model mentions) + const fileMatches = [...input.matchAll(MentionProcessorService.MENTION_PATTERNS.file)] + const processedAgentMentions = new Set(agentMentions.map(am => am.mention)) + + for (const match of fileMatches) { + const mention = match[1] + + // Skip if this is an agent or ask-model mention (already processed) + if (mention.startsWith('run-agent-') || mention.startsWith('agent-') || mention.startsWith('ask-') || processedAgentMentions.has(mention)) { + continue + } + + // Check if it's a file + const filePath = this.resolveFilePath(mention) + if (existsSync(filePath)) { + result.files.push({ + type: 'file', + mention, + resolved: filePath, + exists: true, + }) + result.hasFileMentions = true + + // Emit file mention event for system reminder to handle + emitReminderEvent('file:mentioned', { + filePath: filePath, + originalMention: mention, + timestamp: Date.now(), + }) + } + } + + return result + } catch (error) { + console.warn('[MentionProcessor] Failed to process mentions:', { + input: input.substring(0, 100) + (input.length > 100 ? '...' : ''), + error: error instanceof Error ? error.message : error + }) + + // Return empty result on error to maintain system stability + return { + agents: [], + files: [], + hasAgentMentions: false, + hasFileMentions: false, + } + } + } + + // Removed identifyMention method as it's no longer needed with separate processing + + /** + * Resolve file path relative to current working directory + */ + private resolveFilePath(mention: string): string { + // Simple consistent logic: mention is always relative to current directory + return resolve(getCwd(), mention) + } + + /** + * Refresh the agent cache periodically + * This avoids hitting the agent loader on every mention + */ + private async refreshAgentCache(): Promise { + const now = Date.now() + if (now - this.lastAgentCheck < this.CACHE_TTL) { + return // Cache is still fresh + } + + try { + const agents = await getAvailableAgentTypes() + const previousCacheSize = this.agentCache.size + this.agentCache.clear() + + for (const agent of agents) { + // Store only the agent type without prefix for consistent lookup + this.agentCache.set(agent.agentType, true) + } + + this.lastAgentCheck = now + + // Log cache refresh for debugging mention resolution issues + if (agents.length !== previousCacheSize) { + console.log('[MentionProcessor] Agent cache refreshed:', { + agentCount: agents.length, + previousCacheSize, + cacheAge: now - this.lastAgentCheck + }) + } + } catch (error) { + console.warn('[MentionProcessor] Failed to refresh agent cache, keeping existing cache:', { + error: error instanceof Error ? error.message : error, + cacheSize: this.agentCache.size, + lastRefresh: new Date(this.lastAgentCheck).toISOString() + }) + // Keep existing cache on error to maintain functionality + } + } + + /** + * Extract agent mentions with unified pattern matching + * Consolidates run-agent, agent, and ask-model detection logic + */ + private extractAgentMentions(input: string): Array<{ mention: string; agentType: string; isAskModel: boolean }> { + const mentions: Array<{ mention: string; agentType: string; isAskModel: boolean }> = [] + + // Process @run-agent-xxx format (preferred) + const runAgentMatches = [...input.matchAll(MentionProcessorService.MENTION_PATTERNS.runAgent)] + for (const match of runAgentMatches) { + const mention = match[1] + const agentType = mention.replace(/^run-agent-/, '') + mentions.push({ mention, agentType, isAskModel: false }) + } + + // Process @agent-xxx format (legacy) + const agentMatches = [...input.matchAll(MentionProcessorService.MENTION_PATTERNS.agent)] + for (const match of agentMatches) { + const mention = match[1] + const agentType = mention.replace(/^agent-/, '') + mentions.push({ mention, agentType, isAskModel: false }) + } + + // Process @ask-model mentions + const askModelMatches = [...input.matchAll(MentionProcessorService.MENTION_PATTERNS.askModel)] + for (const match of askModelMatches) { + const mention = match[1] + mentions.push({ mention, agentType: mention, isAskModel: true }) + } + + return mentions + } + + /** + * Emit agent mention event with proper typing + * Centralized event emission to ensure consistency + */ + private emitAgentMentionEvent(mention: string, agentType: string, isAskModel: boolean): void { + try { + const eventData = { + originalMention: mention, + timestamp: Date.now(), + } + + if (isAskModel) { + emitReminderEvent('ask-model:mentioned', { + ...eventData, + modelName: mention, + }) + } else { + emitReminderEvent('agent:mentioned', { + ...eventData, + agentType, + }) + } + + // Debug log for mention event emission tracking + console.log('[MentionProcessor] Emitted mention event:', { + type: isAskModel ? 'ask-model' : 'agent', + mention, + agentType: isAskModel ? undefined : agentType + }) + } catch (error) { + console.error('[MentionProcessor] Failed to emit mention event:', { + mention, + agentType, + isAskModel, + error: error instanceof Error ? error.message : error + }) + } + } + + /** + * Clear caches - useful for testing or reset + */ + public clearCache(): void { + this.agentCache.clear() + this.lastAgentCheck = 0 + } +} + +// Export singleton instance +export const mentionProcessor = new MentionProcessorService() + +/** + * Process mentions in user input + * This is the main API for the mention processor + */ +export const processMentions = (input: string) => + mentionProcessor.processMentions(input) + +/** + * Clear mention processor caches + */ +export const clearMentionCache = () => + mentionProcessor.clearCache() \ No newline at end of file diff --git a/src/services/systemReminder.ts b/src/services/systemReminder.ts index 503903e..4a2a6fd 100644 --- a/src/services/systemReminder.ts +++ b/src/services/systemReminder.ts @@ -82,28 +82,31 @@ class SystemReminderService { () => this.dispatchTodoEvent(agentId), () => this.dispatchSecurityEvent(), () => this.dispatchPerformanceEvent(), + () => this.getMentionReminders(), // Add mention reminders ] for (const generator of reminderGenerators) { - if (reminders.length >= 3) break // Limit concurrent reminders + if (reminders.length >= 5) break // Slightly increase limit to accommodate mentions - const reminder = generator() - if (reminder) { - reminders.push(reminder) - this.sessionState.reminderCount++ + const result = generator() + if (result) { + // Handle both single reminders and arrays + const remindersToAdd = Array.isArray(result) ? result : [result] + reminders.push(...remindersToAdd) + this.sessionState.reminderCount += remindersToAdd.length } } // Log aggregated metrics instead of individual events for performance if (reminders.length > 0) { logEvent('system_reminder_batch', { - count: reminders.length, + count: reminders.length.toString(), types: reminders.map(r => r.type).join(','), priorities: reminders.map(r => r.priority).join(','), categories: reminders.map(r => r.category).join(','), - sessionCount: this.sessionState.reminderCount, + sessionCount: this.sessionState.reminderCount.toString(), agentId: agentId || 'default', - timestamp: currentTime, + timestamp: currentTime.toString(), }) } @@ -224,6 +227,43 @@ class SystemReminderService { return null } + /** + * Retrieve cached mention reminders + * Returns recent mentions (within 5 seconds) that haven't expired + */ + private getMentionReminders(): ReminderMessage[] { + const currentTime = Date.now() + const MENTION_FRESHNESS_WINDOW = 5000 // 5 seconds + const reminders: ReminderMessage[] = [] + const expiredKeys: string[] = [] + + // Single pass through cache for both collection and cleanup identification + for (const [key, reminder] of this.reminderCache.entries()) { + if (this.isMentionReminder(reminder)) { + const age = currentTime - reminder.timestamp + if (age <= MENTION_FRESHNESS_WINDOW) { + reminders.push(reminder) + } else { + expiredKeys.push(key) + } + } + } + + // Clean up expired mention reminders in separate pass for performance + expiredKeys.forEach(key => this.reminderCache.delete(key)) + + return reminders + } + + /** + * Type guard for mention reminders - centralized type checking + * Eliminates hardcoded type strings scattered throughout the code + */ + private isMentionReminder(reminder: ReminderMessage): boolean { + const mentionTypes = ['agent_mention', 'file_mention', 'ask_model_mention'] + return mentionTypes.includes(reminder.type) + } + /** * Generate reminders for external file changes * Called when todo files are modified externally @@ -302,9 +342,9 @@ class SystemReminderService { // Log session startup logEvent('system_reminder_session_startup', { agentId: context.agentId || 'default', - contextKeys: Object.keys(context.context || {}), - messageCount: context.messages || 0, - timestamp: context.timestamp, + contextKeys: Object.keys(context.context || {}).join(','), + messageCount: (context.messages || 0).toString(), + timestamp: context.timestamp.toString(), }) }) @@ -343,6 +383,40 @@ class SystemReminderService { this.addEventListener('file:edited', context => { // File edit handling }) + + // Unified mention event handlers - eliminates code duplication + this.addEventListener('agent:mentioned', context => { + this.createMentionReminder({ + type: 'agent_mention', + key: `agent_mention_${context.agentType}_${context.timestamp}`, + category: 'task', + priority: 'high', + content: `The user mentioned @${context.originalMention}. You MUST use the Task tool with subagent_type="${context.agentType}" to delegate this task to the specified agent. Provide a detailed, self-contained task description that fully captures the user's intent for the ${context.agentType} agent to execute.`, + timestamp: context.timestamp + }) + }) + + this.addEventListener('file:mentioned', context => { + this.createMentionReminder({ + type: 'file_mention', + key: `file_mention_${context.filePath}_${context.timestamp}`, + category: 'general', + priority: 'high', + content: `The user mentioned @${context.originalMention}. You MUST read the entire content of the file at path: ${context.filePath} using the Read tool to understand the full context before proceeding with the user's request.`, + timestamp: context.timestamp + }) + }) + + this.addEventListener('ask-model:mentioned', context => { + this.createMentionReminder({ + type: 'ask_model_mention', + key: `ask_model_mention_${context.modelName}_${context.timestamp}`, + category: 'task', + priority: 'high', + content: `The user mentioned @${context.modelName}. You MUST use the AskExpertModelTool to consult this specific model for expert opinions and analysis. Provide the user's question or context clearly to get the most relevant response from ${context.modelName}.`, + timestamp: context.timestamp + }) + }) } public addEventListener( @@ -366,6 +440,33 @@ class SystemReminderService { }) } + /** + * Unified mention reminder creation - eliminates duplicate logic + * Centralizes reminder creation with consistent deduplication + */ + private createMentionReminder(params: { + type: string + key: string + category: ReminderMessage['category'] + priority: ReminderMessage['priority'] + content: string + timestamp: number + }): void { + if (!this.sessionState.remindersSent.has(params.key)) { + this.sessionState.remindersSent.add(params.key) + + const reminder = this.createReminderMessage( + params.type, + params.category, + params.priority, + params.content, + params.timestamp + ) + + this.reminderCache.set(params.key, reminder) + } + } + public resetSession(): void { this.sessionState = { lastTodoUpdate: 0, diff --git a/src/tools/AskExpertModelTool/AskExpertModelTool.tsx b/src/tools/AskExpertModelTool/AskExpertModelTool.tsx index 95b3d3f..abf1f61 100644 --- a/src/tools/AskExpertModelTool/AskExpertModelTool.tsx +++ b/src/tools/AskExpertModelTool/AskExpertModelTool.tsx @@ -22,7 +22,9 @@ import { debug as debugLogger } from '../../utils/debugLogger' import { applyMarkdown } from '../../utils/markdown' export const inputSchema = z.strictObject({ - question: z.string().describe('The question to ask the expert model'), + question: z.string().describe( + 'COMPLETE SELF-CONTAINED QUESTION: Must include full background context, relevant details, and a clear independent question. The expert model will receive ONLY this content with no access to previous conversation or external context. Structure as: 1) Background/Context 2) Specific situation/problem 3) Clear question. Ensure the expert can fully understand and respond without needing additional information.' + ), expert_model: z .string() .describe( @@ -45,29 +47,40 @@ export type Out = { export const AskExpertModelTool = { name: 'AskExpertModel', async description() { - return 'Consults external AI models for specialized assistance and second opinions' + return "Consult external AI models for expert opinions and analysis" }, async prompt() { - return `Consults external AI models for specialized assistance and second opinions. Maintains conversation history through persistent sessions. + return `Ask a question to a specific external AI model for expert analysis. -When to use AskExpertModel tool: -- User explicitly requests a specific model ("use GPT-5 to...", "ask Claude about...", "consult Kimi for...") -- User seeks second opinions or specialized model expertise -- User requests comparison between different model responses -- Complex questions requiring specific model capabilities +This tool allows you to consult different AI models for their unique perspectives and expertise. -When NOT to use AskExpertModel tool: -- General questions that don't specify a particular model -- Tasks better suited for current model capabilities -- Simple queries not requiring external expertise +CRITICAL REQUIREMENT FOR QUESTION PARAMETER: +The question MUST be completely self-contained and include: +1. FULL BACKGROUND CONTEXT - All relevant information the expert needs +2. SPECIFIC SITUATION - Clear description of the current scenario/problem +3. INDEPENDENT QUESTION - What exactly you want the expert to analyze/answer -Usage notes: -1. Use exact model names as specified by the user -2. Sessions persist conversation context - use "new" for fresh conversations or provide existing session ID -3. External models operate independently without access to current project context -4. Tool validates model availability and provides alternatives if model not found +The expert model receives ONLY your question content with NO access to: +- Previous conversation history (unless using existing session) +- Current codebase or file context +- User's current task or project details -IMPORTANT: Always use the precise model name the user requested. The tool will handle model availability and provide guidance for unavailable models.` +IMPORTANT: This tool is for asking questions to models, not for task execution. +- Use when you need a specific model's opinion or analysis +- Use when you want to compare different models' responses +- Use the @ask-[model] format when available + +The expert_model parameter accepts: +- OpenAI: gpt-4, gpt-5, o1-preview +- Anthropic: claude-3-5-sonnet, claude-3-opus +- Others: kimi, gemini-pro, mixtral + +Example of well-structured question: +"Background: I'm working on a React TypeScript application with performance issues. The app renders a large list of 10,000 items using a simple map() function, causing UI freezing. + +Current situation: Users report 3-5 second delays when scrolling through the list. The component re-renders the entire list on every state change. + +Question: What are the most effective React optimization techniques for handling large lists, and how should I prioritize implementing virtualization vs memoization vs other approaches?"` }, isReadOnly() { return true @@ -89,11 +102,12 @@ IMPORTANT: Always use the precise model name the user requested. The tool will h question, expert_model, chat_session_id, - }): Promise { + }, context?: any): Promise { if (!question.trim()) { return { result: false, message: 'Question cannot be empty' } } + if (!expert_model.trim()) { return { result: false, message: 'Expert model must be specified' } } @@ -106,6 +120,35 @@ IMPORTANT: Always use the precise model name the user requested. The tool will h } } + // Check if trying to consult the same model we're currently running + try { + const modelManager = getModelManager() + + // Get current model based on context + let currentModel: string + if (context?.agentId && context?.options?.model) { + // In subagent context (Task tool) + currentModel = context.options.model + } else { + // In main agent context or after model switch + currentModel = modelManager.getModelName('main') || '' + } + + // Normalize model names for comparison + const normalizedExpert = expert_model.toLowerCase().replace(/[^a-z0-9]/g, '') + const normalizedCurrent = currentModel.toLowerCase().replace(/[^a-z0-9]/g, '') + + if (normalizedExpert === normalizedCurrent) { + return { + result: false, + message: `You are already running as ${currentModel}. Consulting the same model would be redundant. Please choose a different model or handle the task directly.` + } + } + } catch (e) { + // If we can't determine current model, allow the request + debugLogger('AskExpertModel', 'Could not determine current model:', e) + } + // Validate that the model exists and is available try { const modelManager = getModelManager() @@ -142,65 +185,72 @@ IMPORTANT: Always use the precise model name the user requested. The tool will h ) { if (!question || !expert_model) return null const isNewSession = chat_session_id === 'new' - const sessionDisplay = isNewSession ? 'new session' : chat_session_id + const sessionDisplay = isNewSession ? 'new session' : `session ${chat_session_id.substring(0, 5)}...` + const theme = getTheme() if (verbose) { - const theme = getTheme() return ( - {expert_model}, {sessionDisplay} - + {expert_model} + {sessionDisplay} + - {applyMarkdown(question)} + {question.length > 300 ? question.substring(0, 300) + '...' : question} ) } - return `${expert_model}, ${sessionDisplay}` + return ( + + {expert_model} + ({sessionDisplay}) + + ) }, renderToolResultMessage(content) { - const verbose = false // Set default value for verbose + const verbose = true // Show more content const theme = getTheme() if (typeof content === 'object' && content && 'expertAnswer' in content) { const expertResult = content as Out - const isError = expertResult.expertAnswer.startsWith('❌') + const isError = expertResult.expertAnswer.startsWith('Error') || expertResult.expertAnswer.includes('failed') const isInterrupted = expertResult.chatSessionId === 'interrupted' if (isInterrupted) { return ( -   ⎿   - [Expert consultation interrupted] + Consultation interrupted ) } const answerText = verbose ? expertResult.expertAnswer.trim() - : expertResult.expertAnswer.length > 120 - ? expertResult.expertAnswer.substring(0, 120) + '...' + : expertResult.expertAnswer.length > 500 + ? expertResult.expertAnswer.substring(0, 500) + '...' : expertResult.expertAnswer.trim() + if (isError) { + return ( + + {answerText} + + ) + } + return ( - - - {isError ? answerText : applyMarkdown(answerText)} + Response from {expertResult.expertModelName}: + + + {applyMarkdown(answerText)} + + + + + Session: {expertResult.chatSessionId.substring(0, 8)} @@ -209,8 +259,7 @@ IMPORTANT: Always use the precise model name the user requested. The tool will h return ( -   ⎿   - Expert consultation completed + Consultation completed ) }, @@ -315,6 +364,14 @@ ${output.expertAnswer}` return yield* this.handleInterrupt() } + // Yield progress message to show we're connecting + yield { + type: 'progress', + content: createAssistantMessage( + `Connecting to ${expertModel}... (timeout: 5 minutes)` + ), + } + // Call model with comprehensive error handling and timeout let response try { @@ -333,7 +390,7 @@ ${output.expertAnswer}` }) // Create a timeout promise to prevent hanging - const timeoutMs = 60000 // 60 seconds timeout + const timeoutMs = 300000 // 300 seconds (5 minutes) timeout for external models const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error(`Expert model query timed out after ${timeoutMs/1000}s`)) @@ -370,19 +427,25 @@ ${output.expertAnswer}` if (error.message?.includes('timed out')) { throw new Error( - `Expert model '${expertModel}' timed out. This often happens with slower APIs. Try again or use a different model.`, + `Expert model '${expertModel}' timed out after 5 minutes.\n\n` + + `Suggestions:\n` + + ` - The model might be experiencing high load\n` + + ` - Try a different model or retry later\n` + + ` - Consider breaking down your question into smaller parts`, ) } if (error.message?.includes('rate limit')) { throw new Error( - 'Rate limit exceeded for expert model. Please try again later.', + `Rate limit exceeded for ${expertModel}.\n\n` + + `Please wait a moment and try again, or use a different model.`, ) } if (error.message?.includes('invalid api key')) { throw new Error( - 'Invalid API key for expert model. Please check your configuration.', + `Invalid API key for ${expertModel}.\n\n` + + `Please check your model configuration with /model command.`, ) } diff --git a/src/tools/TaskTool/TaskTool.tsx b/src/tools/TaskTool/TaskTool.tsx index dfa1aa7..e80415d 100644 --- a/src/tools/TaskTool/TaskTool.tsx +++ b/src/tools/TaskTool/TaskTool.tsx @@ -2,7 +2,7 @@ import { TextBlock } from '@anthropic-ai/sdk/resources/index.mjs' import chalk from 'chalk' import { last, memoize } from 'lodash-es' import { EOL } from 'os' -import * as React from 'react' +import React, { useState, useEffect } from 'react' import { Box, Text } from 'ink' import { z } from 'zod' import { Tool, ValidationResult } from '../../Tool' @@ -32,6 +32,7 @@ import { generateAgentId } from '../../utils/agentStorage' import { debug as debugLogger } from '../../utils/debugLogger' import { getTaskTools, getPrompt } from './prompt' import { TOOL_NAME } from './constants' +import { getActiveAgents, getAgentByType, getAvailableAgentTypes } from '../../utils/agentLoader' const inputSchema = z.object({ description: z @@ -44,46 +45,28 @@ const inputSchema = z.object({ .describe( 'Optional: Specific model name to use for this task. If not provided, uses the default task model pointer.', ), + subagent_type: z + .string() + .optional() + .describe( + 'The type of specialized agent to use for this task', + ), }) export const TaskTool = { async prompt({ safeMode }) { + // Match original Claude Code - prompt returns full agent descriptions return await getPrompt(safeMode) }, name: TOOL_NAME, async description() { - const modelManager = getModelManager() - const availableModels = modelManager.getAllAvailableModelNames() - const currentTaskModel = - modelManager.getModelName('task') || '' - - if (availableModels.length === 0) { - return `Launch a new agent to handle complex, multi-step tasks autonomously. - -⚠️ No models configured. Use /model to configure models first. - -Usage: Provide detailed task description for autonomous execution. The agent will return results in a single response.` - } - - return `Launch a new agent to handle complex, multi-step tasks autonomously. - -Available models: ${availableModels.join(', ')} - -When to specify a model_name: -- Specify model_name for tasks requiring specific model capabilities -- If not provided, uses current task default: '${currentTaskModel}' -- Use reasoning models for complex analysis -- Use quick models for simple operations - -The model_name parameter accepts actual model names (like 'claude-3-5-sonnet-20241022', 'gpt-4', etc.) - -Usage: Provide detailed task description for autonomous execution. The agent will return results in a single response.` + // Match original Claude Code exactly - simple description + return "Launch a new task" }, inputSchema, - // 🔧 ULTRA FIX: Complete revert to original AgentTool pattern async *call( - { description, prompt, model_name }, + { description, prompt, model_name, subagent_type }, { abortController, options: { safeMode = false, forkNumber, messageLogName, verbose }, @@ -91,14 +74,72 @@ Usage: Provide detailed task description for autonomous execution. The agent wil }, ) { const startTime = Date.now() - const messages: MessageType[] = [createUserMessage(prompt)] - const tools = await getTaskTools(safeMode) + + // Default to general-purpose if no subagent_type specified + const agentType = subagent_type || 'general-purpose' + + // Apply subagent configuration + let effectivePrompt = prompt + let effectiveModel = model_name || 'task' + let toolFilter = null + let temperature = undefined + + // Load agent configuration dynamically + if (agentType) { + const agentConfig = await getAgentByType(agentType) + + if (!agentConfig) { + // If agent type not found, return helpful message instead of throwing + const availableTypes = await getAvailableAgentTypes() + const helpMessage = `Agent type '${agentType}' not found.\n\nAvailable agents:\n${availableTypes.map(t => ` • ${t}`).join('\n')}\n\nUse /agents command to manage agent configurations.` + + yield { + type: 'result', + data: { error: helpMessage }, + resultForAssistant: helpMessage, + } + return + } + + // Apply system prompt if configured + if (agentConfig.systemPrompt) { + effectivePrompt = `${agentConfig.systemPrompt}\n\n${prompt}` + } + + // Apply model if not overridden by model_name parameter + if (!model_name && agentConfig.model_name) { + // Support inherit: keep pointer-based default + if (agentConfig.model_name !== 'inherit') { + effectiveModel = agentConfig.model_name as string + } + } + + // Store tool filter for later application + toolFilter = agentConfig.tools + + // Note: temperature is not currently in our agent configs + // but could be added in the future + } + + const messages: MessageType[] = [createUserMessage(effectivePrompt)] + let tools = await getTaskTools(safeMode) + + // Apply tool filtering if specified by subagent config + if (toolFilter) { + // Back-compat: ['*'] means all tools + const isAllArray = Array.isArray(toolFilter) && toolFilter.length === 1 && toolFilter[0] === '*' + if (toolFilter === '*' || isAllArray) { + // no-op, keep all tools + } else if (Array.isArray(toolFilter)) { + tools = tools.filter(tool => toolFilter.includes(tool.name)) + } + } // We yield an initial message immediately so the UI // doesn't move around when messages start streaming back. yield { type: 'progress', - content: createAssistantMessage(chalk.dim('Initializing…')), + content: createAssistantMessage(chalk.dim(`[${agentType}] ${description}`)), normalizedMessages: normalizeMessages(messages), tools, } @@ -109,8 +150,8 @@ Usage: Provide detailed task description for autonomous execution. The agent wil getMaxThinkingTokens(messages), ]) - // Simple model resolution - match original AgentTool pattern - const modelToUse = model_name || 'task' + // Model already resolved in effectiveModel variable above + const modelToUse = effectiveModel // Inject model context to prevent self-referential expert consultations taskPrompt.push(`\nIMPORTANT: You are currently running as ${modelToUse}. You do not need to consult ${modelToUse} via AskExpertModel since you ARE ${modelToUse}. Complete tasks directly using your capabilities.`) @@ -125,6 +166,23 @@ Usage: Provide detailed task description for autonomous execution. The agent wil const taskId = generateAgentId() // 🔧 ULTRA SIMPLIFIED: Exact original AgentTool pattern + // Build query options, adding temperature if specified + const queryOptions = { + safeMode, + forkNumber, + messageLogName, + tools, + commands: [], + verbose, + maxThinkingTokens, + model: modelToUse, + } + + // Add temperature if specified by subagent config + if (temperature !== undefined) { + queryOptions['temperature'] = temperature + } + for await (const message of query( messages, taskPrompt, @@ -132,16 +190,7 @@ Usage: Provide detailed task description for autonomous execution. The agent wil hasPermissionsToUseTool, { abortController, - options: { - safeMode, - forkNumber, - messageLogName, - tools, - commands: [], - verbose, - maxThinkingTokens, - model: modelToUse, - }, + options: queryOptions, messageId: getLastAssistantMessageId(messages), agentId: taskId, readFileTimestamps, @@ -159,22 +208,55 @@ Usage: Provide detailed task description for autonomous execution. The agent wil } const normalizedMessages = normalizeMessages(messages) + + // Process tool uses and text content for better visibility for (const content of message.message.content) { - if (content.type !== 'tool_use') { - continue - } - - toolUseCount++ - yield { - type: 'progress', - content: normalizedMessages.find( + if (content.type === 'text' && content.text && content.text !== INTERRUPT_MESSAGE) { + // Show agent's reasoning/responses + const preview = content.text.length > 200 ? content.text.substring(0, 200) + '...' : content.text + yield { + type: 'progress', + content: createAssistantMessage(`[${agentType}] ${preview}`), + normalizedMessages, + tools, + } + } else if (content.type === 'tool_use') { + toolUseCount++ + + // Show which tool is being used with agent context + const toolMessage = normalizedMessages.find( _ => _.type === 'assistant' && _.message.content[0]?.type === 'tool_use' && _.message.content[0].id === content.id, - ) as AssistantMessage, - normalizedMessages, - tools, + ) as AssistantMessage + + if (toolMessage) { + // Clone and modify the message to show agent context + const modifiedMessage = { + ...toolMessage, + message: { + ...toolMessage.message, + content: toolMessage.message.content.map(c => { + if (c.type === 'tool_use' && c.id === content.id) { + // Add agent context to tool name display + return { + ...c, + name: c.name // Keep original name, UI will handle display + } + } + return c + }) + } + } + + yield { + type: 'progress', + content: modifiedMessage, + normalizedMessages, + tools, + } + } } } } @@ -210,7 +292,7 @@ Usage: Provide detailed task description for autonomous execution. The agent wil ] yield { type: 'progress', - content: createAssistantMessage(`Done (${result.join(' · ')})`), + content: createAssistantMessage(`[${agentType}] Completed (${result.join(' · ')})`), normalizedMessages, tools, } @@ -265,13 +347,30 @@ Usage: Provide detailed task description for autonomous execution. The agent wil } } + // Validate subagent_type if provided + if (input.subagent_type) { + const availableTypes = await getAvailableAgentTypes() + if (!availableTypes.includes(input.subagent_type)) { + return { + result: false, + message: `Agent type '${input.subagent_type}' does not exist. Available types: ${availableTypes.join(', ')}`, + meta: { + subagent_type: input.subagent_type, + availableTypes, + }, + } + } + } + return { result: true } }, async isEnabled() { return true }, - userFacingName() { - return 'Task' + userFacingName(input?: any) { + // Return agent name with proper prefix + const agentType = input?.subagent_type || 'general-purpose' + return `agent-${agentType}` }, needsPermissions() { return false @@ -279,24 +378,25 @@ Usage: Provide detailed task description for autonomous execution. The agent wil renderResultForAssistant(data: TextBlock[]) { return data.map(block => block.type === 'text' ? block.text : '').join('\n') }, - renderToolUseMessage({ description, prompt, model_name }, { verbose }) { + renderToolUseMessage({ description, prompt, model_name, subagent_type }, { verbose }) { if (!description || !prompt) return null const modelManager = getModelManager() const defaultTaskModel = modelManager.getModelName('task') const actualModel = model_name || defaultTaskModel + const agentType = subagent_type || 'general-purpose' const promptPreview = prompt.length > 80 ? prompt.substring(0, 80) + '...' : prompt + const theme = getTheme() + if (verbose) { - const theme = getTheme() return ( - - 🚀 Task ({actualModel}): {description} + + [{agentType}] {actualModel}: {description} @@ -362,4 +463,4 @@ Usage: Provide detailed task description for autonomous execution. The agent wil ) }, -} satisfies Tool \ No newline at end of file +} satisfies Tool diff --git a/src/tools/TaskTool/prompt.ts b/src/tools/TaskTool/prompt.ts index 9b7f836..ce05332 100644 --- a/src/tools/TaskTool/prompt.ts +++ b/src/tools/TaskTool/prompt.ts @@ -8,6 +8,7 @@ import { NotebookEditTool } from '../NotebookEditTool/NotebookEditTool' import { GlobTool } from '../GlobTool/GlobTool' import { FileReadTool } from '../FileReadTool/FileReadTool' import { getModelManager } from '../../utils/model' +import { getActiveAgents } from '../../utils/agentLoader' export async function getTaskTools(safeMode: boolean): Promise { // No recursive tasks, yet.. @@ -17,40 +18,75 @@ export async function getTaskTools(safeMode: boolean): Promise { } export async function getPrompt(safeMode: boolean): Promise { - const tools = await getTaskTools(safeMode) - const toolNames = tools.map(_ => _.name).join(', ') + // Extracted directly from original Claude Code obfuscated source + const agents = await getActiveAgents() + + // Format exactly as in original: (Tools: tool1, tool2) + const agentDescriptions = agents.map(agent => { + const toolsStr = Array.isArray(agent.tools) + ? agent.tools.join(', ') + : '*' + return `- ${agent.agentType}: ${agent.whenToUse} (Tools: ${toolsStr})` + }).join('\n') + + // 100% exact copy from original Claude Code source + return `Launch a new agent to handle complex, multi-step tasks autonomously. - // Add dynamic model information for Task tool prompts - const modelManager = getModelManager() - const availableModels = modelManager.getAllAvailableModelNames() - const currentTaskModel = - modelManager.getModelName('task') || '' +Available agent types and the tools they have access to: +${agentDescriptions} - const modelInfo = - availableModels.length > 0 - ? ` +When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. -Available models for Task tool: ${availableModels.join(', ')} -Default task model: ${currentTaskModel} -Specify model_name parameter to use a specific model for the task.` - : '' +When to use the Agent tool: +- When you are instructed to execute custom slash commands. Use the Agent tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py") - return `Launch a new agent that has access to the following tools: ${toolNames}. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use the Task tool to perform the search for you.${modelInfo} - -When to use the Task tool: -- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", the Task tool is strongly recommended - -When NOT to use the Task tool: -- If you want to read a specific file path, use the ${FileReadTool.name} or ${GlobTool.name} tool instead of the Task tool, to find the match more quickly +When NOT to use the Agent tool: +- If you want to read a specific file path, use the ${FileReadTool.name} or ${GlobTool.name} tool instead of the Agent tool, to find the match more quickly - If you are searching for a specific class definition like "class Foo", use the ${GlobTool.name} tool instead, to find the match more quickly -- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly -- Writing code and running bash commands (use other tools for that) -- Other tasks that are not related to searching for a keyword or file +- If you are searching for code within a specific file or set of 2-3 files, use the ${FileReadTool.name} tool instead of the Agent tool, to find the match more quickly +- Other tasks that are not related to the agent descriptions above Usage notes: 1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses 2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. 3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. 4. The agent's outputs should generally be trusted -5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent` +5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent +6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. + +Example usage: + + +"code-reviewer": use this agent after you are done writing a signficant piece of code +"greeting-responder": use this agent when to respond to user greetings with a friendly joke + + + +user: "Please write a function that checks if a number is prime" +assistant: Sure let me write a function that checks if a number is prime +assistant: First let me use the ${FileWriteTool.name} tool to write a function that checks if a number is prime +assistant: I'm going to use the ${FileWriteTool.name} tool to write the following code: + +function isPrime(n) { + if (n <= 1) return false + for (let i = 2; i * i <= n; i++) { + if (n % i === 0) return false + } + return true +} + + +Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code + +assistant: Now let me use the code-reviewer agent to review the code +assistant: Uses the Task tool to launch the with the code-reviewer agent + + + +user: "Hello" + +Since the user is greeting, use the greeting-responder agent to respond with a friendly joke + +assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent" +` } diff --git a/src/utils/advancedFuzzyMatcher.ts b/src/utils/advancedFuzzyMatcher.ts new file mode 100644 index 0000000..0f80bed --- /dev/null +++ b/src/utils/advancedFuzzyMatcher.ts @@ -0,0 +1,290 @@ +/** + * Advanced Fuzzy Matching Algorithm + * + * Inspired by: + * - Chinese Pinyin input methods (Sogou, Baidu) + * - IDE intelligent completion (VSCode, IntelliJ) + * - Terminal fuzzy finders (fzf, peco) + * + * Key features: + * - Hyphen-aware matching (dao → dao-qi-harmony) + * - Numeric suffix matching (py3 → python3) + * - Abbreviation matching (dq → dao-qi) + * - Subsequence matching + * - Word boundary bonus + */ + +export interface MatchResult { + score: number + matched: boolean + algorithm: string +} + +export class AdvancedFuzzyMatcher { + /** + * Main matching function - combines multiple algorithms + */ + match(candidate: string, query: string): MatchResult { + // Normalize inputs + const text = candidate.toLowerCase() + const pattern = query.toLowerCase() + + // Quick exact match - give HUGE score for exact matches + if (text === pattern) { + return { score: 10000, matched: true, algorithm: 'exact' } + } + + // Try all algorithms and combine scores + const algorithms = [ + this.exactPrefixMatch(text, pattern), + this.hyphenAwareMatch(text, pattern), + this.wordBoundaryMatch(text, pattern), + this.abbreviationMatch(text, pattern), + this.numericSuffixMatch(text, pattern), + this.subsequenceMatch(text, pattern), + this.fuzzySegmentMatch(text, pattern), + ] + + // Get best score + let bestScore = 0 + let bestAlgorithm = 'none' + + for (const result of algorithms) { + if (result.score > bestScore) { + bestScore = result.score + bestAlgorithm = result.algorithm + } + } + + return { + score: bestScore, + matched: bestScore > 10, + algorithm: bestAlgorithm + } + } + + /** + * Exact prefix matching + */ + private exactPrefixMatch(text: string, pattern: string): { score: number; algorithm: string } { + if (text.startsWith(pattern)) { + const coverage = pattern.length / text.length + // Higher base score for prefix matches to prioritize them + return { score: 1000 + coverage * 500, algorithm: 'prefix' } + } + return { score: 0, algorithm: 'prefix' } + } + + /** + * Hyphen-aware matching (dao → dao-qi-harmony-designer) + * Treats hyphens as optional word boundaries + */ + private hyphenAwareMatch(text: string, pattern: string): { score: number; algorithm: string } { + // Split by hyphens and try to match + const words = text.split('-') + + // Check if pattern matches the beginning of hyphenated words + if (words[0].startsWith(pattern)) { + const coverage = pattern.length / words[0].length + return { score: 300 + coverage * 100, algorithm: 'hyphen-prefix' } + } + + // Check if pattern matches concatenated words (ignoring hyphens) + const concatenated = words.join('') + if (concatenated.startsWith(pattern)) { + const coverage = pattern.length / concatenated.length + return { score: 250 + coverage * 100, algorithm: 'hyphen-concat' } + } + + // Check if pattern matches any word start + for (let i = 0; i < words.length; i++) { + if (words[i].startsWith(pattern)) { + return { score: 200 - i * 10, algorithm: 'hyphen-word' } + } + } + + return { score: 0, algorithm: 'hyphen' } + } + + /** + * Word boundary matching (dq → dao-qi) + * Matches characters at word boundaries + */ + private wordBoundaryMatch(text: string, pattern: string): { score: number; algorithm: string } { + const words = text.split(/[-_\s]+/) + let patternIdx = 0 + let score = 0 + let matched = false + + for (const word of words) { + if (patternIdx >= pattern.length) break + + if (word[0] === pattern[patternIdx]) { + score += 50 // Bonus for word boundary match + patternIdx++ + matched = true + + // Try to match more characters in this word + for (let i = 1; i < word.length && patternIdx < pattern.length; i++) { + if (word[i] === pattern[patternIdx]) { + score += 20 + patternIdx++ + } + } + } + } + + if (matched && patternIdx === pattern.length) { + return { score, algorithm: 'word-boundary' } + } + + return { score: 0, algorithm: 'word-boundary' } + } + + /** + * Abbreviation matching (nde → node, daoqi → dao-qi) + */ + private abbreviationMatch(text: string, pattern: string): { score: number; algorithm: string } { + let textIdx = 0 + let patternIdx = 0 + let score = 0 + let lastMatchIdx = -1 + + while (patternIdx < pattern.length && textIdx < text.length) { + if (text[textIdx] === pattern[patternIdx]) { + // Calculate position score + const gap = lastMatchIdx === -1 ? 0 : textIdx - lastMatchIdx - 1 + + if (textIdx === 0) { + score += 50 // First character match + } else if (lastMatchIdx >= 0 && gap === 0) { + score += 30 // Consecutive match + } else if (text[textIdx - 1] === '-' || text[textIdx - 1] === '_') { + score += 40 // Word boundary match + } else { + score += Math.max(5, 20 - gap * 2) // Distance penalty + } + + lastMatchIdx = textIdx + patternIdx++ + } + textIdx++ + } + + if (patternIdx === pattern.length) { + // Bonus for compact matches + const spread = lastMatchIdx / pattern.length + if (spread <= 3) score += 50 + else if (spread <= 5) score += 30 + + return { score, algorithm: 'abbreviation' } + } + + return { score: 0, algorithm: 'abbreviation' } + } + + /** + * Numeric suffix matching (py3 → python3, np18 → node18) + */ + private numericSuffixMatch(text: string, pattern: string): { score: number; algorithm: string } { + // Check if pattern has numeric suffix + const patternMatch = pattern.match(/^(.+?)(\d+)$/) + if (!patternMatch) return { score: 0, algorithm: 'numeric' } + + const [, prefix, suffix] = patternMatch + + // Check if text ends with same number + if (!text.endsWith(suffix)) return { score: 0, algorithm: 'numeric' } + + // Check if prefix matches start of text + const textWithoutSuffix = text.slice(0, -suffix.length) + if (textWithoutSuffix.startsWith(prefix)) { + const coverage = prefix.length / textWithoutSuffix.length + return { score: 200 + coverage * 100, algorithm: 'numeric-suffix' } + } + + // Check abbreviation match for prefix + const abbrevResult = this.abbreviationMatch(textWithoutSuffix, prefix) + if (abbrevResult.score > 0) { + return { score: abbrevResult.score + 50, algorithm: 'numeric-abbrev' } + } + + return { score: 0, algorithm: 'numeric' } + } + + /** + * Subsequence matching - characters appear in order + */ + private subsequenceMatch(text: string, pattern: string): { score: number; algorithm: string } { + let textIdx = 0 + let patternIdx = 0 + let score = 0 + + while (patternIdx < pattern.length && textIdx < text.length) { + if (text[textIdx] === pattern[patternIdx]) { + score += 10 + patternIdx++ + } + textIdx++ + } + + if (patternIdx === pattern.length) { + // Penalty for spread + const spread = textIdx / pattern.length + score = Math.max(10, score - spread * 5) + return { score, algorithm: 'subsequence' } + } + + return { score: 0, algorithm: 'subsequence' } + } + + /** + * Fuzzy segment matching (dao → dao-qi-harmony) + * Matches segments flexibly + */ + private fuzzySegmentMatch(text: string, pattern: string): { score: number; algorithm: string } { + // Remove hyphens and underscores for matching + const cleanText = text.replace(/[-_]/g, '') + const cleanPattern = pattern.replace(/[-_]/g, '') + + // Check if clean pattern is a prefix of clean text + if (cleanText.startsWith(cleanPattern)) { + const coverage = cleanPattern.length / cleanText.length + return { score: 150 + coverage * 100, algorithm: 'fuzzy-segment' } + } + + // Check if pattern appears anywhere in clean text + const index = cleanText.indexOf(cleanPattern) + if (index !== -1) { + const positionPenalty = index * 5 + return { score: Math.max(50, 100 - positionPenalty), algorithm: 'fuzzy-contains' } + } + + return { score: 0, algorithm: 'fuzzy-segment' } + } +} + +// Export singleton instance and helper functions +export const advancedMatcher = new AdvancedFuzzyMatcher() + +export function matchAdvanced(candidate: string, query: string): MatchResult { + return advancedMatcher.match(candidate, query) +} + +export function matchManyAdvanced( + candidates: string[], + query: string, + minScore: number = 10 +): Array<{ candidate: string; score: number; algorithm: string }> { + return candidates + .map(candidate => { + const result = advancedMatcher.match(candidate, query) + return { + candidate, + score: result.score, + algorithm: result.algorithm + } + }) + .filter(item => item.score >= minScore) + .sort((a, b) => b.score - a.score) +} \ No newline at end of file diff --git a/src/utils/agentLoader.ts b/src/utils/agentLoader.ts new file mode 100644 index 0000000..6f8f4a5 --- /dev/null +++ b/src/utils/agentLoader.ts @@ -0,0 +1,284 @@ +/** + * Agent configuration loader + * Loads agent configurations from markdown files with YAML frontmatter + * Following Claude Code's agent system architecture + */ + +import { existsSync, readFileSync, readdirSync, statSync, watch, FSWatcher } from 'fs' +import { join, resolve } from 'path' +import { homedir } from 'os' +import matter from 'gray-matter' +import { getCwd } from './state' +import { memoize } from 'lodash-es' + +// Track warned agents to avoid spam +const warnedAgents = new Set() + +export interface AgentConfig { + agentType: string // Agent identifier (matches subagent_type) + whenToUse: string // Description of when to use this agent + tools: string[] | '*' // Tool permissions + systemPrompt: string // System prompt content + location: 'built-in' | 'user' | 'project' + color?: string // Optional UI color + model_name?: string // Optional model override +} + +// Built-in general-purpose agent as fallback +const BUILTIN_GENERAL_PURPOSE: AgentConfig = { + agentType: 'general-purpose', + whenToUse: 'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks', + tools: '*', + systemPrompt: `You are a general-purpose agent. Given the user's task, use the tools available to complete it efficiently and thoroughly. + +When to use your capabilities: +- Searching for code, configurations, and patterns across large codebases +- Analyzing multiple files to understand system architecture +- Investigating complex questions that require exploring many files +- Performing multi-step research tasks + +Guidelines: +- For file searches: Use Grep or Glob when you need to search broadly. Use FileRead when you know the specific file path. +- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results. +- Be thorough: Check multiple locations, consider different naming conventions, look for related files. +- Complete tasks directly using your capabilities.`, + location: 'built-in' +} + +/** + * Parse tools field from frontmatter + */ +function parseTools(tools: any): string[] | '*' { + if (!tools) return '*' + if (tools === '*') return '*' + if (Array.isArray(tools)) { + // Ensure all items are strings and filter out non-strings + const filteredTools = tools.filter((t): t is string => typeof t === 'string') + return filteredTools.length > 0 ? filteredTools : '*' + } + if (typeof tools === 'string') { + return [tools] + } + return '*' +} + +/** + * Scan a directory for agent configuration files + */ +async function scanAgentDirectory(dirPath: string, location: 'user' | 'project'): Promise { + if (!existsSync(dirPath)) { + return [] + } + + const agents: AgentConfig[] = [] + + try { + const files = readdirSync(dirPath) + + for (const file of files) { + if (!file.endsWith('.md')) continue + + const filePath = join(dirPath, file) + const stat = statSync(filePath) + + if (!stat.isFile()) continue + + try { + const content = readFileSync(filePath, 'utf-8') + const { data: frontmatter, content: body } = matter(content) + + // Validate required fields + if (!frontmatter.name || !frontmatter.description) { + console.warn(`Skipping ${filePath}: missing required fields (name, description)`) + continue + } + + // Silently ignore deprecated 'model' field - no warnings by default + // Only warn if KODE_DEBUG_AGENTS environment variable is set + if (frontmatter.model && !frontmatter.model_name && !warnedAgents.has(frontmatter.name) && process.env.KODE_DEBUG_AGENTS) { + console.warn(`⚠️ Agent ${frontmatter.name}: 'model' field is deprecated and ignored. Use 'model_name' instead, or omit to use default 'task' model.`) + warnedAgents.add(frontmatter.name) + } + + // Build agent config + const agent: AgentConfig = { + agentType: frontmatter.name, + whenToUse: frontmatter.description.replace(/\\n/g, '\n'), + tools: parseTools(frontmatter.tools), + systemPrompt: body.trim(), + location, + ...(frontmatter.color && { color: frontmatter.color }), + // Only use model_name field, ignore deprecated 'model' field + ...(frontmatter.model_name && { model_name: frontmatter.model_name }) + } + + agents.push(agent) + } catch (error) { + console.warn(`Failed to parse agent file ${filePath}:`, error) + } + } + } catch (error) { + console.warn(`Failed to scan directory ${dirPath}:`, error) + } + + return agents +} + +/** + * Load all agent configurations + */ +async function loadAllAgents(): Promise<{ + activeAgents: AgentConfig[] + allAgents: AgentConfig[] +}> { + try { + // Scan both .claude and .kode directories in parallel + // Claude Code compatibility: support both ~/.claude/agents and ~/.kode/agents + const userClaudeDir = join(homedir(), '.claude', 'agents') + const userKodeDir = join(homedir(), '.kode', 'agents') + const projectClaudeDir = join(getCwd(), '.claude', 'agents') + const projectKodeDir = join(getCwd(), '.kode', 'agents') + + const [userClaudeAgents, userKodeAgents, projectClaudeAgents, projectKodeAgents] = await Promise.all([ + scanAgentDirectory(userClaudeDir, 'user'), + scanAgentDirectory(userKodeDir, 'user'), + scanAgentDirectory(projectClaudeDir, 'project'), + scanAgentDirectory(projectKodeDir, 'project') + ]) + + // Built-in agents (currently just general-purpose) + const builtinAgents = [BUILTIN_GENERAL_PURPOSE] + + // Apply priority override: built-in < .claude (user) < .kode (user) < .claude (project) < .kode (project) + const agentMap = new Map() + + // Add in priority order (later entries override earlier ones) + for (const agent of builtinAgents) { + agentMap.set(agent.agentType, agent) + } + for (const agent of userClaudeAgents) { + agentMap.set(agent.agentType, agent) + } + for (const agent of userKodeAgents) { + agentMap.set(agent.agentType, agent) + } + for (const agent of projectClaudeAgents) { + agentMap.set(agent.agentType, agent) + } + for (const agent of projectKodeAgents) { + agentMap.set(agent.agentType, agent) + } + + const activeAgents = Array.from(agentMap.values()) + const allAgents = [...builtinAgents, ...userClaudeAgents, ...userKodeAgents, ...projectClaudeAgents, ...projectKodeAgents] + + return { activeAgents, allAgents } + } catch (error) { + console.error('Failed to load agents, falling back to built-in:', error) + return { + activeAgents: [BUILTIN_GENERAL_PURPOSE], + allAgents: [BUILTIN_GENERAL_PURPOSE] + } + } +} + +// Memoized version for performance +export const getActiveAgents = memoize( + async (): Promise => { + const { activeAgents } = await loadAllAgents() + return activeAgents + } +) + +// Get all agents (both active and overridden) +export const getAllAgents = memoize( + async (): Promise => { + const { allAgents } = await loadAllAgents() + return allAgents + } +) + +// Clear cache when needed +export function clearAgentCache() { + getActiveAgents.cache?.clear?.() + getAllAgents.cache?.clear?.() + getAgentByType.cache?.clear?.() + getAvailableAgentTypes.cache?.clear?.() +} + +// Get a specific agent by type +export const getAgentByType = memoize( + async (agentType: string): Promise => { + const agents = await getActiveAgents() + return agents.find(agent => agent.agentType === agentType) + } +) + +// Get all available agent types for validation +export const getAvailableAgentTypes = memoize( + async (): Promise => { + const agents = await getActiveAgents() + return agents.map(agent => agent.agentType) + } +) + +// File watcher for hot reload +let watchers: FSWatcher[] = [] + +/** + * Start watching agent configuration directories for changes + */ +export async function startAgentWatcher(onChange?: () => void): Promise { + await stopAgentWatcher() // Clean up any existing watchers + + // Watch both .claude and .kode directories + const userClaudeDir = join(homedir(), '.claude', 'agents') + const userKodeDir = join(homedir(), '.kode', 'agents') + const projectClaudeDir = join(getCwd(), '.claude', 'agents') + const projectKodeDir = join(getCwd(), '.kode', 'agents') + + const watchDirectory = (dirPath: string, label: string) => { + if (existsSync(dirPath)) { + const watcher = watch(dirPath, { recursive: false }, async (eventType, filename) => { + if (filename && filename.endsWith('.md')) { + console.log(`🔄 Agent configuration changed in ${label}: ${filename}`) + clearAgentCache() + // Also clear any other related caches + getAllAgents.cache?.clear?.() + onChange?.() + } + }) + watchers.push(watcher) + } + } + + // Watch all directories + watchDirectory(userClaudeDir, 'user/.claude') + watchDirectory(userKodeDir, 'user/.kode') + watchDirectory(projectClaudeDir, 'project/.claude') + watchDirectory(projectKodeDir, 'project/.kode') +} + +/** + * Stop watching agent configuration directories + */ +export async function stopAgentWatcher(): Promise { + const closePromises = watchers.map(watcher => + new Promise((resolve) => { + try { + watcher.close((err) => { + if (err) { + console.error('Failed to close file watcher:', err) + } + resolve() + }) + } catch (error) { + console.error('Error closing watcher:', error) + resolve() + } + }) + ) + + await Promise.allSettled(closePromises) + watchers = [] +} \ No newline at end of file diff --git a/src/utils/commonUnixCommands.ts b/src/utils/commonUnixCommands.ts new file mode 100644 index 0000000..47765e6 --- /dev/null +++ b/src/utils/commonUnixCommands.ts @@ -0,0 +1,139 @@ +/** + * Common Unix Commands Database + * + * A curated list of 500+ most frequently used Unix/Linux commands + * for developers and system administrators. + * + * Categories: + * - File & Directory Operations + * - Text Processing + * - Process Management + * - Network Tools + * - Development Tools + * - System Administration + * - Package Management + * - Version Control + */ + +export const COMMON_UNIX_COMMANDS = [ + // File & Directory Operations (50+) + 'ls', 'cd', 'pwd', 'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'touch', 'cat', + 'less', 'more', 'head', 'tail', 'file', 'stat', 'ln', 'readlink', 'basename', 'dirname', + 'find', 'locate', 'which', 'whereis', 'type', 'tree', 'du', 'df', 'mount', 'umount', + 'chmod', 'chown', 'chgrp', 'umask', 'setfacl', 'getfacl', 'lsattr', 'chattr', 'realpath', 'mktemp', + 'rsync', 'scp', 'sftp', 'ftp', 'wget', 'curl', 'tar', 'gzip', 'gunzip', 'zip', + 'unzip', 'bzip2', 'bunzip2', 'xz', 'unxz', '7z', 'rar', 'unrar', 'zcat', 'zless', + + // Text Processing (50+) + 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'cut', 'paste', + 'sort', 'uniq', 'wc', 'tr', 'col', 'column', 'expand', 'unexpand', 'fold', 'fmt', + 'pr', 'nl', 'od', 'hexdump', 'xxd', 'strings', 'split', 'csplit', 'join', 'comm', + 'diff', 'sdiff', 'vimdiff', 'patch', 'diffstat', 'cmp', 'md5sum', 'sha1sum', 'sha256sum', 'sha512sum', + 'base64', 'uuencode', 'uudecode', 'rev', 'tac', 'shuf', 'jq', 'yq', 'xmllint', 'tidy', + + // Process Management (40+) + 'ps', 'top', 'htop', 'atop', 'iotop', 'iftop', 'nethogs', 'pgrep', 'pkill', 'kill', + 'killall', 'jobs', 'bg', 'fg', 'nohup', 'disown', 'nice', 'renice', 'ionice', 'taskset', + 'pstree', 'fuser', 'lsof', 'strace', 'ltrace', 'ptrace', 'gdb', 'valgrind', 'time', 'timeout', + 'watch', 'screen', 'tmux', 'byobu', 'dtach', 'nmon', 'dstat', 'vmstat', 'iostat', 'mpstat', + + // Network Tools (50+) + 'ping', 'ping6', 'traceroute', 'tracepath', 'mtr', 'netstat', 'ss', 'ip', 'ifconfig', 'route', + 'arp', 'hostname', 'hostnamectl', 'nslookup', 'dig', 'host', 'whois', 'nc', 'netcat', 'ncat', + 'socat', 'telnet', 'ssh', 'ssh-keygen', 'ssh-copy-id', 'ssh-add', 'ssh-agent', 'sshd', 'tcpdump', 'wireshark', + 'tshark', 'nmap', 'masscan', 'zmap', 'iptables', 'ip6tables', 'firewall-cmd', 'ufw', 'fail2ban', 'nginx', + 'apache2', 'httpd', 'curl', 'wget', 'aria2', 'axel', 'links', 'lynx', 'w3m', 'elinks', + + // Development Tools - Languages (60+) + 'gcc', 'g++', 'clang', 'clang++', 'make', 'cmake', 'autoconf', 'automake', 'libtool', 'pkg-config', + 'python', 'python2', 'python3', 'pip', 'pip2', 'pip3', 'pipenv', 'poetry', 'virtualenv', 'pyenv', + 'node', 'npm', 'uv', 'npx', 'yarn', 'pnpm', 'nvm', 'volta', 'deno', 'bun', 'tsx', + 'ruby', 'gem', 'bundle', 'bundler', 'rake', 'rbenv', 'rvm', 'irb', 'pry', 'rails', + 'java', 'javac', 'jar', 'javadoc', 'maven', 'mvn', 'gradle', 'ant', 'kotlin', 'kotlinc', + 'go', 'gofmt', 'golint', 'govet', 'godoc', 'rust', 'rustc', 'cargo', 'rustup', 'rustfmt', + + // Development Tools - Utilities (40+) + 'git', 'svn', 'hg', 'bzr', 'cvs', 'fossil', 'tig', 'gitk', 'git-flow', 'hub', + 'gh', 'glab', 'docker', 'docker-compose', 'podman', 'kubectl', 'helm', 'minikube', 'kind', 'k3s', + 'vagrant', 'terraform', 'ansible', 'puppet', 'chef', 'salt', 'packer', 'consul', 'vault', 'nomad', + 'vim', 'vi', 'nvim', 'emacs', 'nano', 'pico', 'ed', 'code', 'subl', 'atom', + + // Database & Data Tools (30+) + 'mysql', 'mysqldump', 'mysqladmin', 'psql', 'pg_dump', 'pg_restore', 'sqlite3', 'redis-cli', 'mongo', 'mongodump', + 'mongorestore', 'cqlsh', 'influx', 'clickhouse-client', 'mariadb', 'cockroach', 'etcdctl', 'consul', 'vault', 'nomad', + 'jq', 'yq', 'xmlstarlet', 'csvkit', 'miller', 'awk', 'sed', 'perl', 'lua', 'tcl', + + // System Administration (50+) + 'sudo', 'su', 'passwd', 'useradd', 'userdel', 'usermod', 'groupadd', 'groupdel', 'groupmod', 'id', + 'who', 'w', 'last', 'lastlog', 'finger', 'chfn', 'chsh', 'login', 'logout', 'exit', + 'systemctl', 'service', 'journalctl', 'systemd-analyze', 'init', 'telinit', 'runlevel', 'shutdown', 'reboot', 'halt', + 'poweroff', 'uptime', 'uname', 'hostname', 'hostnamectl', 'timedatectl', 'localectl', 'loginctl', 'machinectl', 'bootctl', + 'cron', 'crontab', 'at', 'batch', 'anacron', 'systemd-run', 'systemd-timer', 'logrotate', 'logger', 'dmesg', + + // Package Management (30+) + 'apt', 'apt-get', 'apt-cache', 'dpkg', 'dpkg-reconfigure', 'aptitude', 'snap', 'flatpak', 'appimage', 'alien', + 'yum', 'dnf', 'rpm', 'zypper', 'pacman', 'yaourt', 'yay', 'makepkg', 'abs', 'aur', + 'brew', 'port', 'pkg', 'emerge', 'portage', 'nix', 'guix', 'conda', 'mamba', 'micromamba', + + // Monitoring & Performance (30+) + 'top', 'htop', 'atop', 'btop', 'gtop', 'gotop', 'bashtop', 'bpytop', 'glances', 'nmon', + 'sar', 'iostat', 'mpstat', 'vmstat', 'pidstat', 'free', 'uptime', 'tload', 'slabtop', 'powertop', + 'iotop', 'iftop', 'nethogs', 'bmon', 'nload', 'speedtest', 'speedtest-cli', 'fast', 'mtr', 'smokeping', + + // Security Tools (30+) + 'gpg', 'gpg2', 'openssl', 'ssh-keygen', 'ssh-keyscan', 'ssl-cert', 'certbot', 'acme.sh', 'mkcert', 'step', + 'pass', 'keepassxc-cli', 'bitwarden', '1password', 'hashcat', 'john', 'hydra', 'ncrack', 'medusa', 'aircrack-ng', + 'chkrootkit', 'rkhunter', 'clamav', 'clamscan', 'freshclam', 'aide', 'tripwire', 'samhain', 'ossec', 'wazuh', + + // Shell & Scripting (30+) + 'bash', 'sh', 'zsh', 'fish', 'ksh', 'tcsh', 'csh', 'dash', 'ash', 'elvish', + 'export', 'alias', 'unalias', 'history', 'fc', 'source', 'eval', 'exec', 'command', 'builtin', + 'set', 'unset', 'env', 'printenv', 'echo', 'printf', 'read', 'test', 'expr', 'let', + + // Archive & Compression (20+) + 'tar', 'gzip', 'gunzip', 'bzip2', 'bunzip2', 'xz', 'unxz', 'lzma', 'unlzma', 'compress', + 'uncompress', 'zip', 'unzip', '7z', '7za', 'rar', 'unrar', 'ar', 'cpio', 'pax', + + // Media Tools (20+) + 'ffmpeg', 'ffplay', 'ffprobe', 'sox', 'play', 'rec', 'mpg123', 'mpg321', 'ogg123', 'flac', + 'lame', 'oggenc', 'opusenc', 'convert', 'mogrify', 'identify', 'display', 'import', 'animate', 'montage', + + // Math & Calculation (15+) + 'bc', 'dc', 'calc', 'qalc', 'units', 'factor', 'primes', 'seq', 'shuf', 'random', + 'octave', 'maxima', 'sage', 'r', 'julia', + + // Documentation & Help (15+) + 'man', 'info', 'help', 'apropos', 'whatis', 'whereis', 'which', 'type', 'command', 'hash', + 'tldr', 'cheat', 'howdoi', 'stackoverflow', 'explainshell', + + // Miscellaneous Utilities (30+) + 'date', 'cal', 'ncal', 'timedatectl', 'zdump', 'tzselect', 'hwclock', 'ntpdate', 'chrony', 'timeshift', + 'yes', 'true', 'false', 'sleep', 'usleep', 'seq', 'jot', 'shuf', 'tee', 'xargs', + 'parallel', 'rush', 'dsh', 'pssh', 'clusterssh', 'terminator', 'tilix', 'alacritty', 'kitty', 'wezterm', +] as const + +/** + * Get common commands that exist on the current system + * @param systemCommands Array of commands available on the system + * @returns Deduplicated intersection of common commands and system commands + */ +export function getCommonSystemCommands(systemCommands: string[]): string[] { + const systemSet = new Set(systemCommands.map(cmd => cmd.toLowerCase())) + const commonIntersection = COMMON_UNIX_COMMANDS.filter(cmd => systemSet.has(cmd.toLowerCase())) + // Remove duplicates using Set + return Array.from(new Set(commonIntersection)) +} + +/** + * Get a priority score for a command based on its position in the common list + * Earlier commands get higher priority (more commonly used) + */ +export function getCommandPriority(command: string): number { + const index = COMMON_UNIX_COMMANDS.indexOf(command.toLowerCase() as any) + if (index === -1) return 0 + + // Convert index to priority score (earlier = higher score) + const maxScore = 100 + const score = maxScore - (index / COMMON_UNIX_COMMANDS.length) * maxScore + return Math.round(score) +} \ No newline at end of file diff --git a/src/utils/fuzzyMatcher.ts b/src/utils/fuzzyMatcher.ts new file mode 100644 index 0000000..fa4382c --- /dev/null +++ b/src/utils/fuzzyMatcher.ts @@ -0,0 +1,328 @@ +/** + * Input Method Inspired Fuzzy Matching Algorithm + * + * Multi-algorithm weighted scoring system inspired by: + * - Sogou/Baidu Pinyin input method algorithms + * - Double-pinyin abbreviation matching + * - Terminal completion best practices (fzf, zsh, fish) + * + * Designed specifically for command/terminal completion scenarios + * where users type abbreviations like "nde" expecting "node" + */ + +export interface MatchResult { + score: number + algorithm: string // Which algorithm contributed most to the score + confidence: number // 0-1 confidence level +} + +export interface FuzzyMatcherConfig { + // Algorithm weights (must sum to 1.0) + weights: { + prefix: number // Direct prefix matching ("nod" → "node") + substring: number // Substring matching ("ode" → "node") + abbreviation: number // Key chars matching ("nde" → "node") + editDistance: number // Typo tolerance ("noda" → "node") + popularity: number // Common command boost + } + + // Scoring parameters + minScore: number // Minimum score threshold + maxEditDistance: number // Maximum edits allowed + popularCommands: string[] // Commands to boost +} + +const DEFAULT_CONFIG: FuzzyMatcherConfig = { + weights: { + prefix: 0.35, // Strong weight for prefix matching + substring: 0.20, // Good for partial matches + abbreviation: 0.30, // Key for "nde"→"node" cases + editDistance: 0.10, // Typo tolerance + popularity: 0.05 // Slight bias for common commands + }, + minScore: 10, // Lower threshold for better matching + maxEditDistance: 2, + popularCommands: [ + 'node', 'npm', 'git', 'ls', 'cd', 'cat', 'grep', 'find', 'cp', 'mv', + 'python', 'java', 'docker', 'curl', 'wget', 'vim', 'nano' + ] +} + +export class FuzzyMatcher { + private config: FuzzyMatcherConfig + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config } + + // Normalize weights to sum to 1.0 + const weightSum = Object.values(this.config.weights).reduce((a, b) => a + b, 0) + if (Math.abs(weightSum - 1.0) > 0.01) { + Object.keys(this.config.weights).forEach(key => { + this.config.weights[key as keyof typeof this.config.weights] /= weightSum + }) + } + } + + /** + * Calculate fuzzy match score for a candidate against a query + */ + match(candidate: string, query: string): MatchResult { + const text = candidate.toLowerCase() + const pattern = query.toLowerCase() + + // Quick perfect match exits + if (text === pattern) { + return { score: 1000, algorithm: 'exact', confidence: 1.0 } + } + if (text.startsWith(pattern)) { + return { + score: 900 + (10 - pattern.length), + algorithm: 'prefix-exact', + confidence: 0.95 + } + } + + // Run all algorithms + const scores = { + prefix: this.prefixScore(text, pattern), + substring: this.substringScore(text, pattern), + abbreviation: this.abbreviationScore(text, pattern), + editDistance: this.editDistanceScore(text, pattern), + popularity: this.popularityScore(text) + } + + // Weighted combination + const rawScore = Object.entries(scores).reduce((total, [algorithm, score]) => { + const weight = this.config.weights[algorithm as keyof typeof this.config.weights] + return total + (score * weight) + }, 0) + + // Length penalty (prefer shorter commands) + const lengthPenalty = Math.max(0, text.length - 6) * 1.5 + const finalScore = Math.max(0, rawScore - lengthPenalty) + + // Determine primary algorithm and confidence + const maxAlgorithm = Object.entries(scores).reduce((max, [alg, score]) => + score > max.score ? { algorithm: alg, score } : max, + { algorithm: 'none', score: 0 } + ) + + const confidence = Math.min(1.0, finalScore / 100) + + return { + score: finalScore, + algorithm: maxAlgorithm.algorithm, + confidence + } + } + + /** + * Algorithm 1: Prefix Matching (like pinyin prefix) + * Handles cases like "nod" → "node" + */ + private prefixScore(text: string, pattern: string): number { + if (!text.startsWith(pattern)) return 0 + + // Score based on prefix length vs total length + const coverage = pattern.length / text.length + return 100 * coverage + } + + /** + * Algorithm 2: Substring Matching (like pinyin contains) + * Handles cases like "ode" → "node", "py3" → "python3" + */ + private substringScore(text: string, pattern: string): number { + // Direct substring match + const index = text.indexOf(pattern) + if (index !== -1) { + // Earlier position and better coverage = higher score + const positionFactor = Math.max(0, 10 - index) / 10 + const coverageFactor = pattern.length / text.length + return 80 * positionFactor * coverageFactor + } + + // Special handling for numeric suffixes (py3 → python3) + // Check if pattern ends with a number and try prefix match + number + const numMatch = pattern.match(/^(.+?)(\d+)$/) + if (numMatch) { + const [, prefix, num] = numMatch + // Check if text starts with prefix and ends with the same number + if (text.startsWith(prefix) && text.endsWith(num)) { + // Good match for patterns like "py3" → "python3" + const coverageFactor = pattern.length / text.length + return 70 * coverageFactor + 20 // Bonus for numeric suffix match + } + } + + return 0 + } + + /** + * Algorithm 3: Abbreviation Matching (key innovation) + * Handles cases like "nde" → "node", "pyt3" → "python3", "gp5" → "gpt-5" + */ + private abbreviationScore(text: string, pattern: string): number { + let score = 0 + let textPos = 0 + let perfectStart = false + let consecutiveMatches = 0 + let wordBoundaryMatches = 0 + + // Split text by hyphens to handle word boundaries better + const textWords = text.split('-') + const textClean = text.replace(/-/g, '').toLowerCase() + + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i] + let charFound = false + + // Try to find in clean text (no hyphens) + for (let j = textPos; j < textClean.length; j++) { + if (textClean[j] === char) { + charFound = true + + // Check if this character is at a word boundary in original text + let originalPos = 0 + let cleanPos = 0 + for (let k = 0; k < text.length; k++) { + if (text[k] === '-') continue + if (cleanPos === j) { + originalPos = k + break + } + cleanPos++ + } + + // Consecutive character bonus + if (j === textPos) { + consecutiveMatches++ + } else { + consecutiveMatches = 1 + } + + // Position-sensitive scoring + if (i === 0 && j === 0) { + score += 50 // Perfect first character + perfectStart = true + } else if (originalPos === 0 || text[originalPos - 1] === '-') { + score += 35 // Word boundary match + wordBoundaryMatches++ + } else if (j <= 2) { + score += 20 // Early position + } else if (j <= 6) { + score += 10 // Mid position + } else { + score += 5 // Late position + } + + // Consecutive character bonus + if (consecutiveMatches > 1) { + score += consecutiveMatches * 5 + } + + textPos = j + 1 + break + } + } + + if (!charFound) return 0 // Invalid abbreviation + } + + // Critical bonuses + if (perfectStart) score += 30 + if (wordBoundaryMatches >= 2) score += 25 // Multiple word boundaries + if (textPos <= textClean.length * 0.8) score += 15 // Compact abbreviation + + // Special bonus for number matching at end + const lastPatternChar = pattern[pattern.length - 1] + const lastTextChar = text[text.length - 1] + if (/\d/.test(lastPatternChar) && lastPatternChar === lastTextChar) { + score += 25 + } + + return score + } + + /** + * Algorithm 4: Edit Distance (typo tolerance) + * Handles cases like "noda" → "node" + */ + private editDistanceScore(text: string, pattern: string): number { + if (pattern.length > text.length + this.config.maxEditDistance) return 0 + + // Simplified Levenshtein distance + const dp: number[][] = [] + const m = pattern.length + const n = text.length + + // Initialize DP table + for (let i = 0; i <= m; i++) { + dp[i] = [] + for (let j = 0; j <= n; j++) { + if (i === 0) dp[i][j] = j + else if (j === 0) dp[i][j] = i + else { + const cost = pattern[i-1] === text[j-1] ? 0 : 1 + dp[i][j] = Math.min( + dp[i-1][j] + 1, // deletion + dp[i][j-1] + 1, // insertion + dp[i-1][j-1] + cost // substitution + ) + } + } + } + + const distance = dp[m][n] + if (distance > this.config.maxEditDistance) return 0 + + return Math.max(0, 30 - distance * 10) + } + + /** + * Algorithm 5: Command Popularity (like frequency in input method) + * Boost common commands that users frequently type + */ + private popularityScore(text: string): number { + if (this.config.popularCommands.includes(text)) { + return 40 + } + + // Short commands are often more commonly used + if (text.length <= 5) return 10 + + return 0 + } + + /** + * Batch match multiple candidates and return sorted results + */ + matchMany(candidates: string[], query: string): Array<{candidate: string, result: MatchResult}> { + return candidates + .map(candidate => ({ + candidate, + result: this.match(candidate, query) + })) + .filter(item => item.result.score >= this.config.minScore) + .sort((a, b) => b.result.score - a.result.score) + } +} + +// Export convenience functions +export const defaultMatcher = new FuzzyMatcher() + +export function matchCommand(command: string, query: string): MatchResult { + return defaultMatcher.match(command, query) +} + +// Import the advanced matcher +import { matchManyAdvanced } from './advancedFuzzyMatcher' + +export function matchCommands(commands: string[], query: string): Array<{command: string, score: number}> { + // Use the advanced matcher for better results + return matchManyAdvanced(commands, query, 5) // Lower threshold for better matching + .map(item => ({ + command: item.candidate, + score: item.score + })) +} \ No newline at end of file diff --git a/src/utils/messages.tsx b/src/utils/messages.tsx index cb737e7..6767fbd 100644 --- a/src/utils/messages.tsx +++ b/src/utils/messages.tsx @@ -355,7 +355,7 @@ export async function processUserInput( if (input.includes('!`') || input.includes('@')) { try { // Import functions from customCommands service to avoid code duplication - const { executeBashCommands, resolveFileReferences } = await import( + const { executeBashCommands } = await import( '../services/customCommands' ) @@ -366,11 +366,12 @@ export async function processUserInput( processedInput = await executeBashCommands(processedInput) } - // Resolve file references if present + // Process mentions for system reminder integration + // Note: We don't call resolveFileReferences here anymore - + // @file mentions should trigger Read tool usage via reminders, not embed content if (input.includes('@')) { - // Note: This function is not exported from customCommands.ts, so we need to expose it - // For now, we'll keep the local implementation until we refactor the service - processedInput = await resolveFileReferences(processedInput) + const { processMentions } = await import('../services/mentionProcessor') + await processMentions(input) } } catch (error) { console.warn('Dynamic content processing failed:', error) diff --git a/src/utils/theme.ts b/src/utils/theme.ts index cc07aa6..4317c58 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -13,6 +13,9 @@ export interface Theme { success: string error: string warning: string + // UI colors + primary: string + secondary: string diff: { added: string removed: string @@ -33,6 +36,8 @@ const lightTheme: Theme = { success: '#2c7a39', error: '#ab2b3f', warning: '#966c1e', + primary: '#000', + secondary: '#666', diff: { added: '#69db7c', removed: '#ffa8b4', @@ -53,6 +58,8 @@ const lightDaltonizedTheme: Theme = { success: '#006699', // Blue instead of green error: '#cc0000', // Pure red for better distinction warning: '#ff9900', // Orange adjusted for deuteranopia + primary: '#000', + secondary: '#666', diff: { added: '#99ccff', // Light blue instead of green removed: '#ffcccc', // Light red for better contrast @@ -73,6 +80,8 @@ const darkTheme: Theme = { success: '#4eba65', error: '#ff6b80', warning: '#ffc107', + primary: '#fff', + secondary: '#999', diff: { added: '#225c2b', removed: '#7a2936', @@ -93,6 +102,8 @@ const darkDaltonizedTheme: Theme = { success: '#3399ff', // Bright blue instead of green error: '#ff6666', // Bright red for better visibility warning: '#ffcc00', // Yellow-orange for deuteranopia + primary: '#fff', + secondary: '#999', diff: { added: '#004466', // Dark blue instead of green removed: '#660000', // Dark red for better contrast